├── .gitignore
├── Gemfile
├── Gemfile.lock
├── README.md
├── Rakefile
├── app
├── assets
│ ├── .DS_Store
│ ├── images
│ │ ├── .DS_Store
│ │ ├── splitwise-logo-large.png
│ │ ├── splitwise-logo-medium-small.png
│ │ ├── splitwise-logo-small.png
│ │ └── unused
│ │ │ ├── loading-old-1.gif
│ │ │ ├── loading-old.gif
│ │ │ ├── loading.gif
│ │ │ └── splitwise-logo-medium.png
│ ├── javascripts
│ │ ├── application.js
│ │ ├── oauth.js.coffee
│ │ ├── oauth_balance.js.coffee
│ │ ├── oauth_balances_over_time_with_friends.coffee
│ │ ├── oauth_expenses.js.coffee
│ │ ├── oauth_expenses_by_category.js.coffee
│ │ ├── oauth_expenses_by_category_over_time.js.coffee
│ │ └── oauth_expenses_matching.js.coffee
│ └── stylesheets
│ │ ├── application.css
│ │ └── oauth.css.scss
├── controllers
│ ├── application_controller.rb
│ └── user_controller.rb
├── helpers
│ ├── application_helper.rb
│ └── oauth_helper.rb
├── mailers
│ └── .gitkeep
├── models
│ ├── .gitkeep
│ └── user.rb
└── views
│ ├── .DS_Store
│ ├── layouts
│ ├── .DS_Store
│ └── application.html.erb
│ └── user
│ ├── _balance_side_menu.html.erb
│ ├── _create_charts.html.erb
│ ├── _expenses_side_menu.html.erb
│ ├── _side_menu.html.erb
│ ├── _top_menu.html.erb
│ ├── balance_over_time.html.erb
│ ├── balances_over_time_with_friends.html.erb
│ ├── expenses_by_category.html.erb
│ ├── expenses_by_category_over_time.html.erb
│ ├── expenses_matching.html.erb
│ ├── expenses_over_time.html.erb
│ └── welcome.html.erb
├── config.ru
├── config
├── application.rb
├── application.yml
├── application.yml.EXAMPLE
├── boot.rb
├── database.yml
├── environment.rb
├── environments
│ ├── development.rb
│ ├── production.rb
│ └── test.rb
├── initializers
│ ├── backtrace_silencers.rb
│ ├── inflections.rb
│ ├── mime_types.rb
│ ├── secret_token.rb
│ ├── session_store.rb
│ └── wrap_parameters.rb
├── locales
│ └── en.yml
└── routes.rb
├── db
├── migrate
│ ├── 20130424233348_create_oauths.rb
│ └── 20130424235835_add_sessions_table.rb
├── schema.rb
└── seeds.rb
├── doc
└── README_FOR_APP
├── lib
├── assets
│ └── .gitkeep
└── tasks
│ └── .gitkeep
├── log
└── .gitkeep
├── public
├── .DS_Store
├── 404.html
├── 422.html
├── 500.html
├── favicon.ico
└── robots.txt
├── script
└── rails
├── test
├── fixtures
│ ├── .gitkeep
│ └── oauths.yml
├── functional
│ ├── .gitkeep
│ └── oauth_controller_test.rb
├── integration
│ └── .gitkeep
├── performance
│ └── browsing_test.rb
├── test_helper.rb
└── unit
│ ├── .gitkeep
│ ├── helpers
│ └── oauth_helper_test.rb
│ └── oauth_test.rb
└── vendor
├── assets
├── javascripts
│ └── .gitkeep
└── stylesheets
│ └── .gitkeep
└── plugins
└── .gitkeep
/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 | #
3 | # If you find yourself ignoring temporary files generated by your text editor
4 | # or operating system, you probably want to add a global ignore instead:
5 | # git config --global core.excludesfile ~/.gitignore_global
6 |
7 | # Ignore bundler config
8 | /.bundle
9 |
10 | # Ignore the default SQLite database.
11 | /db/*.sqlite3
12 |
13 | # Ignore all logfiles and tempfiles.
14 | /log/*.log
15 | /tmp
16 |
17 | # Ignore application configuration
18 | /config/application.yml
19 | /public/assets
20 | /log
21 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gem 'rails', '3.2.11'
4 |
5 | # Bundle edge Rails instead:
6 | # gem 'rails', :git => 'git://github.com/rails/rails.git'
7 |
8 | gem "figaro" # for local configuration settings that shouldn't be shared on GitHub
9 | gem "thin" # use thin as a webserver
10 | gem "oauth-plugin", "~> 0.4.0"
11 |
12 | group :development do
13 | gem 'sqlite3'
14 | gem "better_errors"
15 | gem "binding_of_caller"
16 | end
17 |
18 | group :production do
19 | gem 'pg'
20 | end
21 |
22 | # Gems used only for assets and not required
23 | # in production environments by default.
24 | group :assets do
25 | gem 'sass-rails', '~> 3.2.3'
26 | gem 'coffee-rails', '~> 3.2.1'
27 |
28 | # See https://github.com/sstephenson/execjs#readme for more supported runtimes
29 | # gem 'therubyracer', :platforms => :ruby
30 |
31 | gem 'uglifier', '>= 1.0.3'
32 | end
33 |
34 | gem 'jquery-rails'
35 |
36 | gem "therubyracer"
37 | gem "less-rails" #Sprockets (what Rails 3.1 uses for its asset pipeline) supports LESS
38 | gem "twitter-bootstrap-rails"
39 |
40 | # To use ActiveModel has_secure_password
41 | # gem 'bcrypt-ruby', '~> 3.0.0'
42 |
43 | # To use Jbuilder templates for JSON
44 | # gem 'jbuilder'
45 |
46 | # Use unicorn as the app server
47 | # gem 'unicorn'
48 |
49 | # Deploy with Capistrano
50 | # gem 'capistrano'
51 |
52 | # To use debugger
53 | # gem 'debugger'
54 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | actionmailer (3.2.11)
5 | actionpack (= 3.2.11)
6 | mail (~> 2.4.4)
7 | actionpack (3.2.11)
8 | activemodel (= 3.2.11)
9 | activesupport (= 3.2.11)
10 | builder (~> 3.0.0)
11 | erubis (~> 2.7.0)
12 | journey (~> 1.0.4)
13 | rack (~> 1.4.0)
14 | rack-cache (~> 1.2)
15 | rack-test (~> 0.6.1)
16 | sprockets (~> 2.2.1)
17 | activemodel (3.2.11)
18 | activesupport (= 3.2.11)
19 | builder (~> 3.0.0)
20 | activerecord (3.2.11)
21 | activemodel (= 3.2.11)
22 | activesupport (= 3.2.11)
23 | arel (~> 3.0.2)
24 | tzinfo (~> 0.3.29)
25 | activeresource (3.2.11)
26 | activemodel (= 3.2.11)
27 | activesupport (= 3.2.11)
28 | activesupport (3.2.11)
29 | i18n (~> 0.6)
30 | multi_json (~> 1.0)
31 | arel (3.0.2)
32 | better_errors (0.8.0)
33 | coderay (>= 1.0.0)
34 | erubis (>= 2.6.6)
35 | binding_of_caller (0.7.1)
36 | debug_inspector (>= 0.0.1)
37 | builder (3.0.4)
38 | coderay (1.0.9)
39 | coffee-rails (3.2.2)
40 | coffee-script (>= 2.2.0)
41 | railties (~> 3.2.0)
42 | coffee-script (2.2.0)
43 | coffee-script-source
44 | execjs
45 | coffee-script-source (1.6.2)
46 | commonjs (0.2.6)
47 | daemons (1.1.9)
48 | debug_inspector (0.0.2)
49 | erubis (2.7.0)
50 | eventmachine (1.0.3)
51 | execjs (1.4.0)
52 | multi_json (~> 1.0)
53 | faraday (0.8.7)
54 | multipart-post (~> 1.1)
55 | figaro (0.6.3)
56 | bundler (~> 1.0)
57 | rails (>= 3, < 5)
58 | hike (1.2.2)
59 | httpauth (0.2.0)
60 | i18n (0.6.4)
61 | journey (1.0.4)
62 | jquery-rails (2.2.1)
63 | railties (>= 3.0, < 5.0)
64 | thor (>= 0.14, < 2.0)
65 | json (1.7.7)
66 | jwt (0.1.8)
67 | multi_json (>= 1.5)
68 | less (2.3.2)
69 | commonjs (~> 0.2.6)
70 | less-rails (2.3.3)
71 | actionpack (>= 3.1)
72 | less (~> 2.3.1)
73 | libv8 (3.11.8.17)
74 | mail (2.4.4)
75 | i18n (>= 0.4.0)
76 | mime-types (~> 1.16)
77 | treetop (~> 1.4.8)
78 | mime-types (1.22)
79 | multi_json (1.7.2)
80 | multi_xml (0.5.3)
81 | multipart-post (1.2.0)
82 | oauth (0.4.7)
83 | oauth-plugin (0.4.1)
84 | multi_json
85 | oauth (~> 0.4.4)
86 | oauth2 (>= 0.5.0)
87 | rack
88 | oauth2 (0.9.1)
89 | faraday (~> 0.8)
90 | httpauth (~> 0.1)
91 | jwt (~> 0.1.4)
92 | multi_json (~> 1.0)
93 | multi_xml (~> 0.5)
94 | rack (~> 1.2)
95 | pg (0.14.1)
96 | polyglot (0.3.3)
97 | rack (1.4.5)
98 | rack-cache (1.2)
99 | rack (>= 0.4)
100 | rack-ssl (1.3.3)
101 | rack
102 | rack-test (0.6.2)
103 | rack (>= 1.0)
104 | rails (3.2.11)
105 | actionmailer (= 3.2.11)
106 | actionpack (= 3.2.11)
107 | activerecord (= 3.2.11)
108 | activeresource (= 3.2.11)
109 | activesupport (= 3.2.11)
110 | bundler (~> 1.0)
111 | railties (= 3.2.11)
112 | railties (3.2.11)
113 | actionpack (= 3.2.11)
114 | activesupport (= 3.2.11)
115 | rack-ssl (~> 1.3.2)
116 | rake (>= 0.8.7)
117 | rdoc (~> 3.4)
118 | thor (>= 0.14.6, < 2.0)
119 | rake (10.0.4)
120 | rdoc (3.12.2)
121 | json (~> 1.4)
122 | ref (1.0.4)
123 | sass (3.2.7)
124 | sass-rails (3.2.6)
125 | railties (~> 3.2.0)
126 | sass (>= 3.1.10)
127 | tilt (~> 1.3)
128 | sprockets (2.2.2)
129 | hike (~> 1.2)
130 | multi_json (~> 1.0)
131 | rack (~> 1.0)
132 | tilt (~> 1.1, != 1.3.0)
133 | sqlite3 (1.3.7)
134 | therubyracer (0.11.4)
135 | libv8 (~> 3.11.8.12)
136 | ref
137 | thin (1.5.1)
138 | daemons (>= 1.0.9)
139 | eventmachine (>= 0.12.6)
140 | rack (>= 1.0.0)
141 | thor (0.18.1)
142 | tilt (1.3.7)
143 | treetop (1.4.12)
144 | polyglot
145 | polyglot (>= 0.3.1)
146 | twitter-bootstrap-rails (2.2.6)
147 | actionpack (>= 3.1)
148 | execjs
149 | railties (>= 3.1)
150 | tzinfo (0.3.37)
151 | uglifier (2.0.1)
152 | execjs (>= 0.3.0)
153 | multi_json (~> 1.0, >= 1.0.2)
154 |
155 | PLATFORMS
156 | ruby
157 |
158 | DEPENDENCIES
159 | better_errors
160 | binding_of_caller
161 | coffee-rails (~> 3.2.1)
162 | figaro
163 | jquery-rails
164 | less-rails
165 | oauth-plugin (~> 0.4.0)
166 | pg
167 | rails (= 3.2.11)
168 | sass-rails (~> 3.2.3)
169 | sqlite3
170 | therubyracer
171 | thin
172 | twitter-bootstrap-rails
173 | uglifier (>= 1.0.3)
174 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # An example Splitwise API app
2 |
3 | a.k.a. Will's awesome first project for Splitwise!
4 |
5 | Also: kittens.
6 |
7 | [](http://splitwise.com/kittens)
8 |
9 |
10 | stuff!
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env rake
2 | # Add your own tasks in files placed in lib/tasks ending in .rake,
3 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
4 |
5 | require File.expand_path('../config/application', __FILE__)
6 |
7 | ApiExample::Application.load_tasks
8 |
--------------------------------------------------------------------------------
/app/assets/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/.DS_Store
--------------------------------------------------------------------------------
/app/assets/images/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/.DS_Store
--------------------------------------------------------------------------------
/app/assets/images/splitwise-logo-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/splitwise-logo-large.png
--------------------------------------------------------------------------------
/app/assets/images/splitwise-logo-medium-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/splitwise-logo-medium-small.png
--------------------------------------------------------------------------------
/app/assets/images/splitwise-logo-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/splitwise-logo-small.png
--------------------------------------------------------------------------------
/app/assets/images/unused/loading-old-1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/unused/loading-old-1.gif
--------------------------------------------------------------------------------
/app/assets/images/unused/loading-old.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/unused/loading-old.gif
--------------------------------------------------------------------------------
/app/assets/images/unused/loading.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/unused/loading.gif
--------------------------------------------------------------------------------
/app/assets/images/unused/splitwise-logo-medium.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/assets/images/unused/splitwise-logo-medium.png
--------------------------------------------------------------------------------
/app/assets/javascripts/application.js:
--------------------------------------------------------------------------------
1 | // This is a manifest file that'll be compiled into application.js, which will include all the files
2 | // listed below.
3 | //
4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
6 | //
7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8 | // the compiled file.
9 | //
10 | // WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD
11 | // GO AFTER THE REQUIRES BELOW.
12 | //
13 | //= require jquery
14 | //= require jquery_ujs
15 | //= require oauth
--------------------------------------------------------------------------------
/app/assets/javascripts/oauth.js.coffee:
--------------------------------------------------------------------------------
1 | # Place all the behaviors and hooks related to the matching controller here.
2 | # All this logic will automatically be available in application.js.
3 | # You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
4 |
5 | # Place all the behaviors and hooks related to the matching controller here.
6 | # All this logic will automatically be available in application.js.
7 | # You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
8 |
9 | $(() ->
10 | $('.top-menu-item.matching').click(() ->
11 | $('#matchbox .input').focus()
12 | )
13 |
14 | $('#matchbox .submit').click(() ->
15 | window.location.href = "/user/expenses_matching?query=#{$('#matchbox input').val()}"
16 | )
17 |
18 | infoboxes = $('.infobox-container')
19 | $.each($('.top-menu-item'), (i, e) ->
20 | unless $(e).hasClass('active')
21 | $(infoboxes[i]).css('left', $(e).position().left)
22 | $(e).hover((() ->
23 | $(infoboxes[i]).addClass('show')
24 | ),(() ->
25 | $(infoboxes[i]).removeClass('show')
26 | ))
27 | )
28 | )
29 |
30 | ### Example arguments:
31 | chartType = 'AreaChart'
32 |
33 | url = '/user/get_balance_over_time?format=google-charts'
34 |
35 | cols = [{id: 'date', type: 'date'}, {id: 'balance', type: 'number'}]
36 |
37 | processRow = ([dateStr, balance]) ->
38 | date = new Date(dateStr)
39 | {c: [{v: date, f: prettyTime(date)}, {v: Number(balance)}]}
40 |
41 |
42 | optionsMainChart =
43 | colors: ['#0088CC']
44 | legend:
45 | position: 'none'
46 | hAxis:
47 | textStyle:
48 | fontName: 'Lucida Grande'
49 | vAxis:
50 | textStyle:
51 | fontName: 'Lucida Grande'
52 | optionsScrollChart =
53 | colors: ['#0088CC']
54 | backgroundColor: '#F5F5F5'
55 | chartArea:
56 | width: '100%'
57 | height: '100%'
58 | legend:
59 | position: 'none'
60 | hAxis:
61 | textPosition: 'none'
62 | vAxis:
63 | textPosition: 'none'
64 | ###
65 |
66 | this.createScrolledChart = (data, variable) ->
67 | debugger
68 | cols = data.cols
69 | rows = data.rows
70 | createCheckin = (n, callback) -> () ->
71 | n -= 1
72 | callback() if n is 0
73 |
74 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
75 |
76 | scrollLabelDate = (t) -> t.toLocaleDateString()
77 | dates = {}
78 | spawnDates = () ->
79 | dates.all = rows.map((r) -> r.c[0].v)
80 | dates.first = +dates.all[0]
81 | dates.span = dates.all[dates.all.length - 1] - dates.first
82 | dateOnScroll = (x) ->
83 | return new Date(dates.first + dates.span * (x / elem.film.offsetParent().width()))
84 |
85 | createScrollChart = () ->
86 | chart = new google.visualization[variable.chartType]($('#scroll-chart')[0])
87 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
88 | google.visualization.events.addListener(chart, 'ready', layerFilm)
89 | chart.draw(dataTable, variable.optionsScrollChart)
90 | return chart
91 | initMainChart = () ->
92 | chart = new google.visualization[variable.chartType]($('#main-chart')[0])
93 | return chart
94 | redrawMainChart = (startDate, finishDate) ->
95 | start = Math.max(0, indexOfDateAfter(startDate) - 1)
96 | finish = indexOfDateAfter(finishDate) + 1
97 | console.debug(cols);
98 | console.debug(rows);
99 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows.slice(start, finish)})
100 | elem.mainChart.draw(dataTable, variable.optionsMainChart)
101 |
102 | redrawMainChartFromScrolled = () ->
103 | redrawMainChart(dateOnScroll(elem.film.position().left),
104 | dateOnScroll(elem.film.position().left + elem.film.width()))
105 | indexOfDateAfter = (date) ->
106 | for d, i in dates.all #optimize this by using a binary search
107 | if date < d
108 | return i
109 | return dates.all.length - 1
110 |
111 | drawCharts = createCheckin((if google.visualization then 1 else 2), () ->
112 | spawnDates()
113 | elem.scrollChart = createScrollChart()
114 | elem.mainChart = initMainChart()
115 | redrawMainChartFromScrolled()
116 |
117 | )
118 |
119 |
120 | filmHandleLeftDraggable = false
121 | filmHandleRightDraggable = false
122 | filmDraggable = false
123 | filmPressedAt = false
124 | elem =
125 | filmBackdrop: $("
"),
126 | film: $("
"),
127 | filmHandleLeft: $("
"),
128 | filmHandleRight: $("
")
129 | filmLabelLeft: $(" ")
130 | filmLabelRight: $(" ")
131 | filmLabelContainerLeft: $(" ")
132 | filmLabelContainerRight: $(" ")
133 | elem.filmBackdrop.append(elem.film.append(elem.filmHandleLeft.append(elem.filmLabelContainerLeft.append(elem.filmLabelLeft)))
134 | .append(elem.filmHandleRight.append(elem.filmLabelContainerRight.append(elem.filmLabelRight))))
135 | elem.filmHandleLeft.mousedown((event) ->
136 | event.preventDefault()
137 | filmHandleLeftDraggable = true
138 | elem.filmLabelLeft.css('visibility', 'visible')
139 | $('body').css('cursor', 'col-resize')
140 | false
141 | )
142 | elem.filmHandleRight.mousedown((event) ->
143 | event.preventDefault()
144 | filmHandleRightDraggable = true
145 | elem.filmLabelRight.css('visibility', 'visible')
146 | $('body').css('cursor', 'col-resize')
147 | false
148 | )
149 | elem.film.mousedown((event) ->
150 | event.preventDefault()
151 | filmDraggable = true
152 | filmPressedAt = event.pageX - $(this).offset().left
153 | )
154 | $(document).mouseup(() ->
155 | if filmHandleLeftDraggable or filmHandleRightDraggable or filmDraggable
156 | filmHandleLeftDraggable = false
157 | filmHandleRightDraggable = false
158 | filmDraggable = false
159 | elem.filmLabelLeft.css('visibility', 'hidden')
160 | elem.filmLabelRight.css('visibility', 'hidden')
161 | $('body').css('cursor', 'default')
162 | redrawMainChartFromScrolled()
163 | )
164 |
165 | onMouseMove = (event) ->
166 | minHandleDistance = 10
167 | newX = event.pageX - elem.film.offsetParent().offset().left
168 | if filmHandleLeftDraggable and minHandleDistance + elem.filmHandleRight.width() + elem.filmHandleLeft.width() < elem.film.width() - (newX - parseFloat(elem.film.css('left'), 10))
169 | newX = Math.max(newX, 0)
170 | elem.film.css('width', elem.film.width() - (newX - parseFloat(elem.film.css('left'), 10)))
171 | elem.film.css('left', newX)
172 | elem.filmLabelLeft.html(scrollLabelDate(dateOnScroll(newX)))
173 | else if filmHandleRightDraggable and minHandleDistance + elem.film.position().left + elem.filmHandleLeft.width() + elem.filmHandleRight.width() < newX
174 | newX = Math.min(newX, elem.film.offsetParent().width())
175 | elem.film.css('width', newX - elem.film.position().left)
176 | elem.filmLabelRight.html(scrollLabelDate(dateOnScroll(newX)))
177 | else if filmDraggable
178 | newX = newX - filmPressedAt
179 | newX = Math.min(Math.max(newX, 0),
180 | elem.film.offsetParent().width() - elem.film.width())
181 | elem.film.css('left', newX)
182 | $(document).mousemove(onMouseMove)
183 |
184 | layerFilm = () ->
185 | g = $('div#scroll-chart > div > div > svg > g')
186 | rect = g.children('rect')
187 | elem.filmBackdrop.offset({top: 0, left: 0}) #I invoke offset with a position because offset seems to work not relative to the document but to the last (possibly positioned) parent. In addition, I supply the position values because e.position() works differently in firefox and webkit.
188 | elem.filmBackdrop.width(rect.attr('width'))
189 | elem.filmBackdrop.height(rect.attr('height'))
190 | $('#scroll-chart').append(elem.filmBackdrop)
191 |
192 | google.setOnLoadCallback(drawCharts)
193 | unless google.visualization
194 | google.load('visualization', '1', {packages: ['corechart']}) unless google.visualization
195 |
196 | ###
197 | rows = undefined
198 | $.ajax({url: variable.url}).done((d) ->
199 |
200 | rows = variable.processData(JSON.parse(d))
201 | drawCharts()
202 |
203 | )
204 | ###
205 |
206 | $(document).ready(() ->
207 |
208 | $('.active a').click(() ->
209 | event.preventDefault()
210 | )
211 | drawCharts()
212 |
213 | )
214 |
215 |
216 | ###
217 | this.activateMatchbox = () ->
218 | $('.side-menu-item.matching').click(() ->
219 | $('.side-menu-item.matching #matchbox-container').css('visibility', 'visible')
220 | false
221 | )
222 | $(document).click(() ->
223 | $('.side-menu-item.matching #matchbox-container').css('visibility', 'hidden')
224 | )
225 | $('#matchbox .submit').click(() ->
226 | window.location.href = "/user/expenses_matching?query=#{$('#matchbox input').val()}"
227 | )
228 | ###
--------------------------------------------------------------------------------
/app/assets/javascripts/oauth_balance.js.coffee:
--------------------------------------------------------------------------------
1 | # Place all the behaviors and hooks related to the matching controller here.
2 | # All this logic will automatically be available in application.js.
3 | # You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
4 |
5 |
6 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
7 |
8 |
9 | options =
10 | chartType: 'LineChart'
11 |
12 | optionsMainChart:
13 | colors: ['#0088CC']
14 | legend:
15 | position: 'none'
16 | textStyle:
17 | fontName: 'Lato, Lucida Grande'
18 | hAxis:
19 | textStyle:
20 | fontName: 'Lato, Lucida Grande'
21 | slantedText: true
22 | vAxis:
23 | textStyle:
24 | fontName: 'Lato, Lucida Grande'
25 | format: '$###,###.##'
26 |
27 | optionsScrollChart:
28 | colors: ['#0088CC']
29 | backgroundColor: '#F5F5F5'
30 | chartArea:
31 | width: '100%'
32 | height: '100%'
33 | legend:
34 | position: 'none'
35 | textStyle:
36 | fontName: 'Lato, Lucida Grande'
37 | hAxis:
38 | textPosition: 'none'
39 | vAxis:
40 | textPosition: 'none'
41 |
42 |
43 | cols = [{id: 'date', type: 'date'}, {id: 'balance', type: 'number'}]
44 |
45 | this.primeCharts = (data) ->
46 | console.debug(data)
47 | rows = data.map((b) ->
48 | date = new Date(b.date)
49 | {c: [{v: date, f: prettyTime(date)}, {v: Number(b.balance), f:"$"+Number(b.balance).toFixed(2)}]}
50 | )
51 |
52 | createScrolledChart({cols: cols, rows: rows}, options)
53 |
54 | ###
55 | url: '/user/get_balance_over_time?format=google-charts'
56 |
57 | processData: (d) -> d.map(([dateStr, balance]) ->
58 | date = new Date(dateStr)
59 | {c: [{v: date, f: prettyTime(date)}, {v: Number(balance), f:"$"+Number(balance).toFixed(2)}]}
60 | )
61 | ###
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | ###
110 | createCheckin = (n, callback) -> () ->
111 | n -= 1
112 | callback() if n is 0
113 |
114 |
115 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getDate()}/#{t.getMonth()+1}"
116 |
117 | scrollLabelDate = (t) -> t.toLocaleDateString()
118 |
119 | cols = [{id: 'date', type: 'date'}, {id: 'balance', type: 'number'}]
120 | rows = undefined
121 | optionsMainChart =
122 | colors: ['#0088CC']
123 | legend:
124 | position: 'none'
125 | hAxis:
126 | textStyle:
127 | fontName: 'Lucida Grande'
128 | vAxis:
129 | textStyle:
130 | fontName: 'Lucida Grande'
131 | optionsScrollChart =
132 | colors: ['#0088CC']
133 | backgroundColor: '#F5F5F5'
134 | chartArea:
135 | width: '100%'
136 | height: '100%'
137 | legend:
138 | position: 'none'
139 | hAxis:
140 | textPosition: 'none'
141 | vAxis:
142 | textPosition: 'none'
143 |
144 | dates = {}
145 | spawnDates = () ->
146 | dates.all = rows.map((r) -> r.c[0].v)
147 | dates.first = +dates.all[0]
148 | dates.span = dates.all[dates.all.length - 1] - dates.first
149 |
150 | dateOnScroll = (x) ->
151 | return new Date(dates.first + dates.span * (x / elem.film.offsetParent().width()))
152 |
153 | initScrollChart = () ->
154 | chart = new google.visualization.AreaChart($('#scroll-chart')[0])
155 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
156 | google.visualization.events.addListener(chart, 'ready', layerFilm)
157 | chart.draw(dataTable, optionsScrollChart)
158 | return chart
159 |
160 | initMainChart = () ->
161 | chart = new google.visualization.AreaChart($('#chart-historical-balance')[0])
162 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
163 | chart.draw(dataTable, optionsMainChart)
164 | return chart
165 |
166 | redrawMainChart = (startDate, finishDate) ->
167 | start = Math.max(0, indexOfDateAfter(startDate) - 1)
168 | finish = indexOfDateAfter(finishDate) + 1
169 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows.slice(start, finish)})
170 | elem.mainChart.draw(dataTable, optionsMainChart)
171 |
172 | indexOfDateAfter = (date) ->
173 | for d, i in dates.all #optimize this by using a binary search
174 | if date < d
175 | return i
176 | return dates.all.length - 1
177 |
178 |
179 | drawCharts = createCheckin(3, () ->
180 |
181 | spawnDates()
182 | elem.scrollChart = initScrollChart()
183 | elem.mainChart = initMainChart()
184 |
185 | )
186 |
187 |
188 |
189 | filmHandleLeftDraggable = false
190 | filmHandleRightDraggable = false
191 |
192 | elem =
193 | filmBackdrop: $("
"),
194 | film: $("
"),
195 | filmHandleLeft: $("
"),
196 | filmHandleRight: $("
")
197 | filmLabelLeft: $(" ")
198 | filmLabelRight: $(" ")
199 | filmLabelContainerLeft: $(" ")
200 | filmLabelContainerRight: $(" ")
201 |
202 | elem.filmBackdrop.append(elem.film.append(elem.filmHandleLeft.append(elem.filmLabelContainerLeft.append(elem.filmLabelLeft)))
203 | .append(elem.filmHandleRight.append(elem.filmLabelContainerRight.append(elem.filmLabelRight))))
204 |
205 | elem.filmHandleLeft.mousedown(() ->
206 | filmHandleLeftDraggable = true
207 | elem.filmLabelLeft.css('visibility', 'visible')
208 | )
209 |
210 | elem.filmHandleRight.mousedown(() ->
211 | filmHandleRightDraggable = true
212 | elem.filmLabelRight.css('visibility', 'visible')
213 | )
214 |
215 | $(document).mouseup(() ->
216 | if filmHandleLeftDraggable or filmHandleRightDraggable
217 | filmHandleLeftDraggable = false
218 | filmHandleRightDraggable = false
219 | elem.filmLabelLeft.css('visibility', 'hidden')
220 | elem.filmLabelRight.css('visibility', 'hidden')
221 | redrawMainChart(dateOnScroll(elem.film.position().left),
222 | dateOnScroll(elem.film.position().left + elem.film.width()))
223 | )
224 |
225 | onMouseMove = (event) ->
226 | minHandleDistance = 10
227 | newX = event.pageX - elem.film.offsetParent().offset().left
228 | if filmHandleLeftDraggable and newX < -minHandleDistance + elem.film.position().left + elem.film.outerWidth() - elem.filmHandleLeft.width() - elem.filmHandleRight.width()
229 | newX = Math.max(newX, 0)
230 | elem.film.css('width', elem.film.outerWidth() - newX + parseInt(elem.film.css('left')), 10)
231 | elem.film.css('left', newX)
232 | elem.filmLabelLeft.html(scrollLabelDate(dateOnScroll(newX)))
233 | else if filmHandleRightDraggable and minHandleDistance + elem.film.position().left + elem.filmHandleLeft.width() + elem.filmHandleRight.width() < newX
234 | newX = Math.min(newX, elem.film.offsetParent().width())
235 | elem.film.css('width', newX - elem.film.position().left)
236 | elem.filmLabelRight.html(scrollLabelDate(dateOnScroll(newX)))
237 |
238 | $(document).mousemove(onMouseMove)
239 |
240 | layerFilm = () ->
241 | g = $('div#scroll-chart > div > div > svg > g')
242 | rect = g.children('rect')
243 | elem.filmBackdrop.offset({top: 0, left: 0}) #I invoke offset with a position because offset seems to work not relative to the document but to the last (possibly positioned) parent. In addition, I supply the position values because e.position() works differently in firefox and webkit.
244 | elem.filmBackdrop.width(rect.attr('width'))
245 | elem.filmBackdrop.height(rect.attr('height'))
246 | $('#scroll-chart').append(elem.filmBackdrop)
247 |
248 |
249 |
250 | google.setOnLoadCallback(drawCharts)
251 | google.load('visualization', '1', {packages: ['corechart']})
252 |
253 | $.ajax({url: '/user/get_balance_over_time?format=google-charts'}).done((d) ->
254 |
255 | rows = JSON.parse(d).map(([dateStr, balance]) ->
256 | date = new Date(dateStr)
257 | {c: [{v: date, f: prettyTime(date)}, {v: Number(balance)}]})
258 | drawCharts()
259 |
260 | )
261 |
262 | $(document).ready(() ->
263 |
264 | $('.top-menu-item.balance').addClass('active')
265 | $('.active a').click(() -> false)
266 | drawCharts()
267 |
268 | )
269 | ###
--------------------------------------------------------------------------------
/app/assets/javascripts/oauth_balances_over_time_with_friends.coffee:
--------------------------------------------------------------------------------
1 |
2 |
3 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
4 |
5 | prettyFriendName = (f) -> "#{f.first_name} #{f.last_name}"
6 |
7 | options =
8 | chartType: 'LineChart'
9 |
10 | optionsMainChart:
11 | legend:
12 | textStyle:
13 | fontName: 'Lato, Lucida Grande'
14 | hAxis:
15 | textStyle:
16 | fontName: 'Lato, Lucida Grande'
17 | slantedText: true
18 | vAxis:
19 | textStyle:
20 | fontName: 'Lato, Lucida Grande'
21 | format: '$###,###.##'
22 |
23 | optionsScrollChart:
24 | backgroundColor: '#F5F5F5'
25 | chartArea:
26 | width: '100%'
27 | height: '100%'
28 | hAxis:
29 | textPosition: 'none'
30 | vAxis:
31 | textPosition: 'none'
32 |
33 |
34 | cols = [{id: 'date', type: 'date'}]
35 |
36 | this.primeCharts = (d) ->
37 | console.debug(d)
38 | friends = d.friends
39 | balance_records = d.balances
40 | rows = []
41 | for friend in friends
42 | cols.push({id: friend.id, label: prettyFriendName(friend), type:'number'})
43 | console.debug(balance_records)
44 | for balance_record in balance_records
45 | console.debug(balance_record)
46 | date = new Date(balance_record.date)
47 | rows.push({
48 | c: [
49 | {
50 | v: date,
51 | f: prettyTime(date)
52 | }
53 | ].concat(balance_record.balances.map((b) -> { v: b or 0, f: "$#{(b or 0).toFixed(2)}"}))
54 | })
55 | console.debug(cols)
56 | console.debug(rows)
57 |
58 | createScrolledChart({cols: cols, rows: rows}, options)
--------------------------------------------------------------------------------
/app/assets/javascripts/oauth_expenses.js.coffee:
--------------------------------------------------------------------------------
1 | # Place all the behaviors and hooks related to the matching controller here.
2 | # All this logic will automatically be available in application.js.
3 | # You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
4 |
5 |
6 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
7 |
8 | monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]
9 | dateToMonth = (t) -> "#{monthNames[t.getMonth()]} #{t.getFullYear()}"
10 |
11 | options =
12 | chartType: 'ColumnChart'
13 |
14 | optionsMainChart:
15 | colors: ['#0088CC']
16 | legend:
17 | position: 'none'
18 | textStyle:
19 | fontName: 'Lato, Lucida Grande'
20 | hAxis:
21 | textStyle:
22 | fontName: 'Lato, Lucida Grande'
23 | slantedText: true
24 | vAxis:
25 | textStyle:
26 | fontName: 'Lato, Lucida Grande'
27 | format: '$###,###.##'
28 |
29 | optionsScrollChart:
30 | colors: ['#0088CC']
31 | backgroundColor: '#F5F5F5'
32 | chartArea:
33 | width: '100%'
34 | height: '100%'
35 | legend:
36 | position: 'none'
37 | hAxis:
38 | textPosition: 'none'
39 | vAxis:
40 | textPosition: 'none'
41 |
42 |
43 | cols = [{id: 'date', type: 'string'}, {id: 'balance', type: 'number'}]
44 |
45 | this.primeCharts = (data) ->
46 | rows = data.map((e) ->
47 | date = new Date(e.date)
48 | {c:
49 | [
50 | {v: dateToMonth(date)},
51 | {
52 | v: Number(e.expense),
53 | f: "Total: $#{e.expense.toFixed(2)}"
54 | }
55 | ]
56 | }
57 | )
58 |
59 | createScrolledChart({cols: cols, rows: rows}, options)
60 |
61 |
62 | ###
63 | createCheckin = (n, callback) -> () ->
64 | n -= 1
65 | callback() if n is 0
66 |
67 |
68 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
69 |
70 | scrollLabelDate = (t) -> t.toLocaleDateString()
71 |
72 | cols = [{id: 'date', type: 'date'}, {id: 'balance', type: 'number'}]
73 | rows = undefined
74 | optionsMainChart =
75 | colors: ['#0088CC']
76 | legend:
77 | position: 'none'
78 | hAxis:
79 | textStyle:
80 | fontName: 'Lucida Grande'
81 | vAxis:
82 | textStyle:
83 | fontName: 'Lucida Grande'
84 | optionsScrollChart =
85 | colors: ['#0088CC']
86 | backgroundColor: '#F5F5F5'
87 | chartArea:
88 | width: '100%'
89 | height: '100%'
90 | legend:
91 | position: 'none'
92 | hAxis:
93 | textPosition: 'none'
94 | vAxis:
95 | textPosition: 'none'
96 |
97 | dates = {}
98 | spawnDates = () ->
99 | dates.all = rows.map((r) -> r.c[0].v)
100 | dates.first = +dates.all[0]
101 | dates.span = dates.all[dates.all.length - 1] - dates.first
102 |
103 | dateOnScroll = (x) ->
104 | return new Date(dates.first + dates.span * (x / elem.film.offsetParent().width()))
105 |
106 | initScrollChart = () ->
107 | chart = new google.visualization.ColumnChart($('#scroll-chart')[0])
108 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
109 | google.visualization.events.addListener(chart, 'ready', layerFilm)
110 | chart.draw(dataTable, optionsScrollChart)
111 | return chart
112 |
113 | initMainChart = () ->
114 | chart = new google.visualization.ColumnChart($('#expenses-chart')[0])
115 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
116 | chart.draw(dataTable, optionsMainChart)
117 | return chart
118 |
119 | redrawMainChart = (startDate, finishDate) ->
120 | start = Math.max(0, indexOfDateAfter(startDate) - 1)
121 | finish = indexOfDateAfter(finishDate) + 1
122 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows.slice(start, finish)})
123 | elem.mainChart.draw(dataTable, optionsMainChart)
124 |
125 | indexOfDateAfter = (date) ->
126 | for d, i in dates.all #optimize this by using a binary search
127 | if date < d
128 | return i
129 | return dates.all.length - 1
130 |
131 |
132 | drawCharts = createCheckin(3, () ->
133 |
134 | spawnDates()
135 | elem.scrollChart = initScrollChart()
136 | elem.mainChart = initMainChart()
137 |
138 | )
139 |
140 |
141 |
142 | filmHandleLeftDraggable = false
143 | filmHandleRightDraggable = false
144 |
145 | elem =
146 | filmBackdrop: $("
"),
147 | film: $("
"),
148 | filmHandleLeft: $("
"),
149 | filmHandleRight: $("
")
150 | filmLabelLeft: $(" ")
151 | filmLabelRight: $(" ")
152 | filmLabelContainerLeft: $(" ")
153 | filmLabelContainerRight: $(" ")
154 |
155 | elem.filmBackdrop.append(elem.film.append(elem.filmHandleLeft.append(elem.filmLabelContainerLeft.append(elem.filmLabelLeft)))
156 | .append(elem.filmHandleRight.append(elem.filmLabelContainerRight.append(elem.filmLabelRight))))
157 |
158 | elem.filmHandleLeft.mousedown(() ->
159 | filmHandleLeftDraggable = true
160 | elem.filmLabelLeft.css('visibility', 'visible')
161 | )
162 |
163 | elem.filmHandleRight.mousedown(() ->
164 | filmHandleRightDraggable = true
165 | elem.filmLabelRight.css('visibility', 'visible')
166 | )
167 |
168 | $(document).mouseup(() ->
169 | if filmHandleLeftDraggable or filmHandleRightDraggable
170 | filmHandleLeftDraggable = false
171 | filmHandleRightDraggable = false
172 | elem.filmLabelLeft.css('visibility', 'hidden')
173 | elem.filmLabelRight.css('visibility', 'hidden')
174 | redrawMainChart(dateOnScroll(elem.film.position().left),
175 | dateOnScroll(elem.film.position().left + elem.film.width()))
176 | )
177 |
178 | onMouseMove = (event) ->
179 | minHandleDistance = 10
180 | newX = event.pageX - elem.film.offsetParent().offset().left
181 | if filmHandleLeftDraggable and newX < -minHandleDistance + elem.film.position().left + elem.film.outerWidth() - elem.filmHandleLeft.width() - elem.filmHandleRight.width()
182 | newX = Math.max(newX, 0)
183 | elem.film.css('width', elem.film.outerWidth() - newX + parseInt(elem.film.css('left')), 10)
184 | elem.film.css('left', newX)
185 | elem.filmLabelLeft.html(scrollLabelDate(dateOnScroll(newX)))
186 | else if filmHandleRightDraggable and minHandleDistance + elem.film.position().left + elem.filmHandleLeft.width() + elem.filmHandleRight.width() < newX
187 | newX = Math.min(newX, elem.film.offsetParent().width())
188 | elem.film.css('width', newX - elem.film.position().left)
189 | elem.filmLabelRight.html(scrollLabelDate(dateOnScroll(newX)))
190 |
191 | $(document).mousemove(onMouseMove)
192 |
193 | layerFilm = () ->
194 | g = $('div#scroll-chart > div > div > svg > g')
195 | rect = g.children('rect')
196 | elem.filmBackdrop.offset({top: 0, left: 0}) #I invoke offset with a position because offset seems to work not relative to the document but to the last (possibly positioned) parent. In addition, I supply the position values because e.position() works differently in firefox and webkit.
197 | elem.filmBackdrop.width(rect.attr('width'))
198 | elem.filmBackdrop.height(rect.attr('height'))
199 | $('#scroll-chart').append(elem.filmBackdrop)
200 |
201 |
202 |
203 | google.setOnLoadCallback(drawCharts)
204 | google.load('visualization', '1', {packages: ['corechart']})
205 |
206 | $.ajax({url: '/user/get_expenses_over_time'}).done((d) ->
207 |
208 | rows = JSON.parse(d).map((e) ->
209 | date = new Date(e.date)
210 | {c: [{v: date, f: prettyTime(date)}, {v: Number(e.expense)}]})
211 | drawCharts()
212 |
213 | )
214 |
215 | $(document).ready(() ->
216 | $('.top-menu-item.expenses').addClass('active')
217 | $('.active a').click(() -> false)
218 | drawCharts()
219 | )
220 |
221 | console.debug(e.expense)
222 | console.debug(typeof e.expense)
223 | console.debug(e.expense.toFixed)
224 | console.debug(e.expense.toFixed(2))
225 | console.debug(e.total)
226 | console.debug(typeof e.total)
227 | console.debug(e.total.toFixed)
228 | console.debug(e.total.toFixed(2))
229 | ###
--------------------------------------------------------------------------------
/app/assets/javascripts/oauth_expenses_by_category.js.coffee:
--------------------------------------------------------------------------------
1 | # Place all the behaviors and hooks related to the matching controller here.
2 | # All this logic will automatically be available in application.js.
3 | # You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
4 |
5 |
6 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
7 |
8 | createCheckin = (n, callback) -> () ->
9 | n -= 1
10 | callback() if n is 0
11 |
12 | cols = [{id: 'Category', type: 'string'}, {id: 'Spending', type: 'number'}]
13 | rows = undefined
14 | options =
15 | legend:
16 | textStyle:
17 | fontName: 'Lato, Lucida Grande'
18 |
19 | drawChart = createCheckin(2, () ->
20 | chart = new google.visualization.PieChart($('#main-chart')[0])
21 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
22 | chart.draw(dataTable, options)
23 | )
24 |
25 | google.setOnLoadCallback(drawChart)
26 | google.load('visualization', '1', {packages: ['corechart']})
27 |
28 | this.primeCharts = (categories) ->
29 | rows = ({c: [{v: category}, {v: spending, f: "$#{spending.toFixed(2)}"}]} for category, spending of categories)
30 |
31 | console.debug(rows)
32 | console.debug(categories)
33 |
34 | drawChart()
35 |
36 |
37 | $(document).ready(() ->
38 |
39 | $('.active a').click(() ->
40 | event.preventDefault()
41 | )
42 |
43 | )
44 |
45 |
46 |
47 | ###
48 | $.ajax({url: '/user/get_expenses_by_category'}).done((jsonData) ->
49 | categories = JSON.parse(jsonData)
50 |
51 | rows = ({c: [{v: category}, {v: spending}]} for category, spending of categories)
52 |
53 | console.debug(rows)
54 | console.debug(categories)
55 |
56 | drawChart()
57 | )
58 |
59 | $(document).ready(() ->
60 | $(activateMatchbox)
61 | )
62 | ###
63 |
64 | ###
65 | options =
66 | chartType: 'ColumnChart'
67 |
68 | cols: [{id: 'date', type: 'date'}]
69 |
70 | url: '/user/get_expenses_by_category'
71 |
72 | processData: (data) ->
73 | for category in data.categories
74 | options.cols.push({id: category, label: category, type: 'number'})
75 |
76 | data.rows.map((r) ->
77 | date = new Date(r.date)
78 | return {c: [{v: date, f: prettyTime(date)}].concat(r.expenses.map((e) ->
79 | {v: Number(e)}
80 | ))}
81 | )
82 |
83 | optionsMainChart:
84 | hAxis:
85 | textStyle:
86 | fontName: 'Lucida Grande'
87 | vAxis:
88 | textStyle:
89 | fontName: 'Lucida Grande'
90 |
91 | optionsScrollChart:
92 | backgroundColor: '#F5F5F5'
93 | chartArea:
94 | width: '100%'
95 | height: '100%'
96 | legend:
97 | position: 'none'
98 | hAxis:
99 | textPosition: 'none'
100 | vAxis:
101 | textPosition: 'none'
102 |
103 | createScrolledChart(options)
104 | ###
105 |
106 |
107 | ###
108 | createCheckin = (n, callback) -> () ->
109 | n -= 1
110 | callback() if n is 0
111 |
112 |
113 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
114 |
115 | scrollLabelDate = (t) -> t.toLocaleDateString()
116 |
117 | cols = undefined
118 | rows = undefined
119 | optionsMainChart =
120 | hAxis:
121 | textStyle:
122 | fontName: 'Lucida Grande'
123 | vAxis:
124 | textStyle:
125 | fontName: 'Lucida Grande'
126 | optionsScrollChart =
127 | backgroundColor: '#F5F5F5'
128 | chartArea:
129 | width: '100%'
130 | height: '100%'
131 | legend:
132 | position: 'none'
133 | hAxis:
134 | textPosition: 'none'
135 | vAxis:
136 | textPosition: 'none'
137 |
138 | dates = {}
139 | spawnDates = () ->
140 | dates.all = rows.map((r) -> r.c[0].v)
141 | dates.first = +dates.all[0]
142 | dates.span = dates.all[dates.all.length - 1] - dates.first
143 |
144 | dateOnScroll = (x) ->
145 | return new Date(dates.first + dates.span * (x / elem.film.offsetParent().width()))
146 |
147 | initScrollChart = () ->
148 | chart = new google.visualization.ColumnChart($('#scroll-chart')[0])
149 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
150 | google.visualization.events.addListener(chart, 'ready', layerFilm)
151 | chart.draw(dataTable, optionsScrollChart)
152 | return chart
153 |
154 | initMainChart = () ->
155 | chart = new google.visualization.ColumnChart($('#expenses-by-category-chart')[0])
156 | console.debug(cols)
157 | console.debug(rows)
158 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows})
159 | chart.draw(dataTable, optionsMainChart)
160 | return chart
161 |
162 | redrawMainChart = (startDate, finishDate) ->
163 | start = Math.max(0, indexOfDateAfter(startDate) - 1)
164 | finish = indexOfDateAfter(finishDate) + 1
165 | console.debug(start)
166 | console.debug(finish)
167 | console.debug(cols)
168 | console.debug(rows)
169 | dataTable = new google.visualization.DataTable({cols: cols, rows: rows.slice(start, finish)})
170 | console.debug(dataTable.toJSON())
171 | elem.mainChart.draw(dataTable, optionsMainChart)
172 |
173 | indexOfDateAfter = (date) ->
174 | for d, i in dates.all #optimize this by using a binary search
175 | if date < d
176 | return i
177 | return dates.all.length - 1
178 |
179 |
180 | drawCharts = createCheckin(3, () ->
181 |
182 | spawnDates()
183 | elem.scrollChart = initScrollChart()
184 | elem.mainChart = initMainChart()
185 |
186 | )
187 |
188 |
189 |
190 | filmHandleLeftDraggable = false
191 | filmHandleRightDraggable = false
192 |
193 | elem =
194 | filmBackdrop: $("
"),
195 | film: $("
"),
196 | filmHandleLeft: $("
"),
197 | filmHandleRight: $("
")
198 | filmLabelLeft: $(" ")
199 | filmLabelRight: $(" ")
200 | filmLabelContainerLeft: $(" ")
201 | filmLabelContainerRight: $(" ")
202 |
203 | elem.filmBackdrop.append(elem.film.append(elem.filmHandleLeft.append(elem.filmLabelContainerLeft.append(elem.filmLabelLeft)))
204 | .append(elem.filmHandleRight.append(elem.filmLabelContainerRight.append(elem.filmLabelRight))))
205 |
206 | elem.filmHandleLeft.mousedown(() ->
207 | filmHandleLeftDraggable = true
208 | elem.filmLabelLeft.css('visibility', 'visible')
209 | )
210 |
211 | elem.filmHandleRight.mousedown(() ->
212 | filmHandleRightDraggable = true
213 | elem.filmLabelRight.css('visibility', 'visible')
214 | )
215 |
216 | $(document).mouseup(() ->
217 | if filmHandleLeftDraggable or filmHandleRightDraggable
218 | filmHandleLeftDraggable = false
219 | filmHandleRightDraggable = false
220 | elem.filmLabelLeft.css('visibility', 'hidden')
221 | elem.filmLabelRight.css('visibility', 'hidden')
222 | redrawMainChart(dateOnScroll(elem.film.position().left),
223 | dateOnScroll(elem.film.position().left + elem.film.width()))
224 | )
225 |
226 | onMouseMove = (event) ->
227 | minHandleDistance = 10
228 | newX = event.pageX - elem.film.offsetParent().offset().left
229 | if filmHandleLeftDraggable and newX < -minHandleDistance + elem.film.position().left + elem.film.outerWidth() - elem.filmHandleLeft.width() - elem.filmHandleRight.width()
230 | newX = Math.max(newX, 0)
231 | elem.film.css('width', elem.film.outerWidth() - newX + parseInt(elem.film.css('left')), 10)
232 | elem.film.css('left', newX)
233 | elem.filmLabelLeft.html(scrollLabelDate(dateOnScroll(newX)))
234 | else if filmHandleRightDraggable and minHandleDistance + elem.film.position().left + elem.filmHandleLeft.width() + elem.filmHandleRight.width() < newX
235 | newX = Math.min(newX, elem.film.offsetParent().width())
236 | elem.film.css('width', newX - elem.film.position().left)
237 | elem.filmLabelRight.html(scrollLabelDate(dateOnScroll(newX)))
238 |
239 | $(document).mousemove(onMouseMove)
240 |
241 | layerFilm = () ->
242 | g = $('div#scroll-chart > div > div > svg > g')
243 | rect = g.children('rect')
244 | elem.filmBackdrop.offset({top: 0, left: 0}) #I invoke offset with a position because offset seems to work not relative to the document but to the last (possibly positioned) parent. In addition, I supply the position values because e.position() works differently in firefox and webkit.
245 | elem.filmBackdrop.width(rect.attr('width'))
246 | elem.filmBackdrop.height(rect.attr('height'))
247 | $('#scroll-chart').append(elem.filmBackdrop)
248 |
249 |
250 |
251 | google.setOnLoadCallback(drawCharts)
252 | google.load('visualization', '1', {packages: ['corechart']})
253 |
254 | $.ajax({url: '/user/get_expenses_by_category'}).done((data) ->
255 | data = JSON.parse(data)
256 |
257 | cols = [{id: 'date', type: 'date'}]
258 | for category in data.categories
259 | cols.push({id: category, label: category, type: 'number'})
260 |
261 | rows = data.rows.map((r) ->
262 | console.debug(r)
263 | date = new Date(r.date)
264 | return {c: [{v: date, f: prettyTime(date)}].concat(r.expenses.map((e) ->
265 | {v: Number(e)}
266 | ))}
267 | )
268 | drawCharts()
269 |
270 | )
271 |
272 | $(document).ready(() ->
273 |
274 | $('.top-menu-item.expenses').addClass('active')
275 | $('.active a').click(() -> false)
276 | drawCharts()
277 |
278 | )
279 | ###
--------------------------------------------------------------------------------
/app/assets/javascripts/oauth_expenses_by_category_over_time.js.coffee:
--------------------------------------------------------------------------------
1 | # Place all the behaviors and hooks related to the matching controller here.
2 | # All this logic will automatically be available in application.js.
3 | # You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
4 |
5 |
6 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
7 |
8 | prettyFriendName = (f) -> "#{f.first_name} #{f.last_name}"
9 |
10 | options =
11 | chartType: 'LineChart'
12 |
13 | optionsMainChart:
14 | legend:
15 | textStyle:
16 | fontName: 'Lato, Lucida Grande'
17 | hAxis:
18 | textStyle:
19 | fontName: 'Lato, Lucida Grande'
20 | slantedText: true
21 | vAxis:
22 | textStyle:
23 | fontName: 'Lato, Lucida Grande'
24 | format: '$###,###.##'
25 |
26 | optionsScrollChart:
27 | backgroundColor: '#F5F5F5'
28 | chartArea:
29 | width: '100%'
30 | height: '100%'
31 | hAxis:
32 | textPosition: 'none'
33 | vAxis:
34 | textPosition: 'none'
35 |
36 |
37 | cols = [{id: 'date', type: 'date'}]
38 |
39 | this.primeCharts = (d) ->
40 | categories = d.categories
41 | expenses_records = d.expenses
42 | rows = []
43 | for category in categories
44 | cols.push({id: category, label: category, type:'number'})
45 | for expenses_record in expenses_records
46 | console.debug(expenses_record)
47 | date = new Date(expenses_record.date)
48 | rows.push({
49 | c: [
50 | {
51 | v: date,
52 | f: prettyTime(date)
53 | }
54 | ].concat(expenses_record.expenses.map((e) -> {
55 | v: e,
56 | f: "$#{e.toFixed(2)}"
57 | }))
58 | })
59 |
60 | console.debug('I will create a scrolled chart...')
61 | console.debug($('#main-chart')[0])
62 | console.debug('g')
63 | createScrolledChart({cols: cols, rows: rows}, options)
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | ###
107 | {
108 | v: Number(e.total),
109 | f: "#{e.description}\nCost: $#{e.expense.toFixed(2)}\nTotal: $#{e.total.toFixed(2)}"
110 | }
111 | ###
--------------------------------------------------------------------------------
/app/assets/javascripts/oauth_expenses_matching.js.coffee:
--------------------------------------------------------------------------------
1 |
2 | urlParams = {}
3 | for [k, v] in window.location.href[(window.location.href.indexOf('?') + 1)..].split(/&/).map((param) -> param.split(/\=/))
4 | urlParams[k] = v
5 |
6 |
7 | compare = (a, b) ->
8 | if a < b
9 | return -1
10 | else if a > b
11 | return 1
12 | else
13 | return 0
14 |
15 | pick = (obj, keys) ->
16 | result = {}
17 | for key in keys
18 | result[key] = obj[key]
19 | return result
20 |
21 | strRepeat = (n, str) ->
22 | if n is 0
23 | ''
24 | else
25 | str + strRepeat(n - 1, str)
26 |
27 | prettyTime = (t) -> "#{t.toLocaleTimeString()} #{t.getMonth()+1}/#{t.getDate()}"
28 |
29 |
30 | options =
31 | chartType: 'LineChart'
32 |
33 | optionsMainChart:
34 | legend:
35 | textStyle:
36 | fontName: 'Lato, Lucida Grande'
37 | hAxis:
38 | textStyle:
39 | fontName: 'Lato, Lucida Grande'
40 | slantedText: true
41 | vAxis:
42 | textStyle:
43 | fontName: 'Lato, Lucida Grande'
44 | format: '$###,###.##'
45 |
46 | optionsScrollChart:
47 | backgroundColor: '#F5F5F5'
48 | chartArea:
49 | width: '100%'
50 | height: '100%'
51 | legend:
52 | position: 'none'
53 | hAxis:
54 | textPosition: 'none'
55 | vAxis:
56 | textPosition: 'none'
57 |
58 |
59 | cols = [{id: 'date', type: 'date'}, {id: 'balance', type: 'number'}]
60 | rows = undefined
61 |
62 | data2Rows = (data) ->
63 | return data.map((e) ->
64 | date = new Date(e.date)
65 | {c:
66 | [
67 | {v: date, f: prettyTime(date)},
68 | {
69 | v: Number(e.total),
70 | f: "#{e.description}\nCost: $#{e.expense.toFixed(2)}\nTotal: $#{e.total.toFixed(2)}"
71 | }
72 | ]
73 | }
74 | )
75 |
76 | searches = [] # of the form {search: String, rows: [{date: String, expense: Number, total: Number, description: String}]}
77 |
78 | aggregatedCols = () ->
79 | result = [{label: 'date', type: 'date'}].concat(searches.map((s) -> {label: s.search, type: 'number'}))
80 |
81 |
82 | return result
83 |
84 | aggregatedRows = () -> # of the form [{date: String, expenses: [{expense: Number, total: Number, description: String}]}]
85 |
86 |
87 |
88 | expenseRecords = {}
89 | for search, i in searches
90 | for expense in search.rows
91 | expenseRecords[expense.date] ?= []
92 | expenseRecords[expense.date][i] = pick(expense, ['expense', 'total', 'description'])
93 |
94 |
95 |
96 |
97 |
98 |
99 | result = []
100 | Object.keys(expenseRecords).sort().forEach((date, i, dates) -> # sort by date
101 |
102 |
103 |
104 | #I check for vacant cells:
105 | for j in [0...searches.length]
106 | if expenseRecords[date][j] is undefined
107 | if i is 0
108 |
109 | expenseRecords[date][j] = {
110 | expense: 0,
111 | total: 0,
112 | description: '(No expense)'
113 | }
114 | else
115 |
116 |
117 | expenseRecords[date][j] = {
118 | expense: 0,
119 | total: expenseRecords[dates[i - 1]][j].total
120 | description: '(No expense)'
121 | }
122 |
123 | result.push(
124 | {
125 | date: date
126 | expenses: expenseRecords[date]
127 | }
128 | )
129 |
130 | )
131 |
132 |
133 |
134 | return result
135 |
136 |
137 | chartData = () ->
138 |
139 |
140 | rows = aggregatedRows().map((r) ->
141 | date = new Date(r.date)
142 | {c:
143 | [
144 | {v: date, f: prettyTime(date)},
145 | ].concat(r.expenses.map((e) ->
146 | return {
147 | v: Number(e.total),
148 | f: "#{e.description}\nCost: $#{e.expense.toFixed(2)}\nTotal: $#{e.total.toFixed(2)}"
149 | }
150 | ))
151 | }
152 | )
153 | cols = aggregatedCols()
154 | return {cols: cols, rows: rows}
155 |
156 | google.load('visualization', '1', {packages: ['corechart']}) unless google.visualization
157 |
158 | elem = {}
159 |
160 | elem.prototypeOfSearchStackItem = $("
161 |
162 |
163 | x
164 |
165 | ")
166 |
167 |
168 | this.primeCharts = (data) ->
169 |
170 | #I removed this when limiting the search interface to one matchbox.
171 | #searches.push({search: urlParams.query, rows: data})
172 |
173 | #createScrolledChart(chartData(), options)
174 |
175 |
176 | chartLoading = (() ->
177 | interval = undefined
178 |
179 | return {
180 | start: () ->
181 | count = 0
182 | interval = setInterval((() ->
183 | $('#loading-film-message').html('Loading' + strRepeat(count % 4, ' .'))
184 | count += 1
185 | ), 300)
186 | $('#loading-film').addClass('show')
187 | stop: () ->
188 | $('#loading-film').removeClass('show')
189 | clearInterval(interval)
190 | $('#loading-film-message').html('Loading')
191 | }
192 |
193 | )()
194 |
195 | SearchStackItem = (search) ->
196 |
197 | result = elem.prototypeOfSearchStackItem.clone()
198 | result.find('.search-name').html(search)
199 | result.find('.delete-search').click(() ->
200 | if searches.length > 1
201 | removeSearchItem($(this).closest('.search-stack-item'))
202 | )
203 |
204 | return result
205 |
206 | addSearchItem = () ->
207 | search = $('#search-stack .matchbox .input').val()
208 | if search isnt ''
209 | $('#search-stack .matchbox .input').val('')
210 | chartLoading.start()
211 | slideInSearchStackItem(SearchStackItem(search))
212 | $.ajax({url: "/user/get_expenses_matching?query=#{search}"}).done((data) ->
213 | searches.push({search: search, rows: JSON.parse(data)})
214 | $('#main-chart').empty()
215 | $('#scroll-chart').empty()
216 | console.debug(createScrolledChart);
217 | createScrolledChart(chartData(), options)
218 | chartLoading.stop()
219 | )
220 |
221 | removeSearchItem = (item) ->
222 | $(item).children('.delete-search').click(() -> false)
223 | console.debug(searches)
224 | for search, i in searches.slice(0)
225 | console.debug("I compare '#{search.search}' to '#{$(item).find('.search-name').html()}' and find them #{if search.search is $(item).find('.search-name').html() then '' else 'un'}equal.")
226 | if search.search is $(item).find('.search-name').html()
227 | searches.splice(i, 1)
228 | break #I break so that it removes only one search item.
229 | console.debug(item)
230 | console.debug($(item).find('.search-name')[0])
231 | console.debug($(item).find('.search-name').html())
232 | console.debug(searches)
233 | slideOutSearchStackItem(item)
234 | $('#main-chart').empty()
235 | $('#scroll-chart').empty()
236 | createScrolledChart(chartData(), options)
237 |
238 |
239 | $(() ->
240 | first = $('.search-stack-item:first-child')
241 | #first.find('.search-name').html(urlParams.query)
242 | sliding.searchItemHeight = $('.search-stack-item').outerHeight()
243 | ###
244 | $('.search-stack-item:first-child').find('.delete-search').click(() ->
245 | if searches.length > 1
246 | removeSearchItem($(this).closest('.search-stack-item'))
247 | )
248 | ###
249 | $('#search-stack .matchbox .submit').click(addSearchItem)
250 | $('#search-stack .matchbox .input').keypress((event) ->
251 | if event.which is 13
252 | event.preventDefault()
253 | addSearchItem()
254 | )
255 | )
256 |
257 | ###
258 | $(() ->
259 | $('.submit').click(() ->
260 | newItem = $("Name
")
261 | newItem.click(() -> slideOutSearchStackItem(this))
262 | slideInSearchStackItem(newItem)
263 | )
264 | )
265 | ###
266 |
267 |
268 |
269 |
270 | #Sliding:
271 | sliding =
272 | insertDuration: 600
273 | removeDuration: 600
274 | searchItemHeight: undefined
275 | framesPerHeight: 2
276 |
277 | lastItemHeightFunc = (t) -> sliding.searchItemHeight * t
278 |
279 |
280 | slideInSearchStackItem = (newItem) ->
281 | lastItem = $('.search-stack-item:last-child')
282 | newItemInner = newItem.children('.search-stack-item-content')
283 |
284 | newItem.css('position', 'absolute')
285 | newItem.css('z-index', '0')
286 | newItemInner.css('display', 'none')
287 | console.log($('#search-stack').offset())
288 | if not $('.search-stack-item:nth-last-child(2)')[0]
289 | newItem.css('top', '0px')
290 | newItem.insertBefore($('.search-stack-item:last-child'))
291 |
292 | afterFadeIn = () ->
293 | clearInterval(moveLastItem)
294 | lastItem.css('margin-top', 0)
295 | newItem.css('position', 'static')
296 | newItem.css('z-index', '1')
297 |
298 | newItemInner.fadeIn(sliding.insertDuration, afterFadeIn)
299 |
300 | sliding.searchItemHeight = $('.search-stack-item:first-child').outerHeight()
301 |
302 | moveLastItem = setInterval((() ->
303 | start = new Date()
304 | return () ->
305 | lastItem.css('margin-top',
306 | lastItemHeightFunc((new Date() - start) / sliding.insertDuration))
307 | )(), sliding.insertDuration / sliding.searchItemHeight / sliding.framesPerHeight)
308 |
309 |
310 |
311 |
312 | slideOutSearchStackItem = (item) ->
313 | item = $(item)
314 | item.off('click')
315 |
316 | next = item.next()
317 |
318 | next.css('margin-top', sliding.searchItemHeight)
319 | item.css('position', 'absolute')
320 | item.css('z-index', '0')
321 | if not $('.search-stack-item:nth-last-child(3)')[0]
322 | item.css('top', $('#search-stack').offset().top + 'px')
323 | console.log('offset: ' + ($('#search-stack').offset().top + 'px'))
324 |
325 | afterFadeOut = () ->
326 | clearInterval(nextTimeout)
327 | next.css('margin-top', 0)
328 | item.remove()
329 |
330 | item.children('.search-stack-item-content').fadeOut(sliding.removeDuration, afterFadeOut)
331 |
332 | nextTimeout = setInterval((() ->
333 | start = new Date()
334 | return () ->
335 | now = new Date()
336 | next.css('margin-top',
337 | lastItemHeightFunc(1 - (now - start) / sliding.removeDuration))
338 | )(), sliding.removeDuration / sliding.searchItemHeight / sliding.framesPerHeight)
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 | ###
376 | console.debug(0)
377 |
378 | this.cuteAlert = (() ->
379 | console.debug(1)
380 | container = $('
')
381 | elem = $('
')
382 | container.css({
383 | position: 'fixed',
384 | top: '0',
385 | left: '0',
386 | right: '0',
387 | bottom: '0',
388 | display: 'none'
389 | })
390 | elem.css({
391 | margin: 'auto',
392 | width: '16em',
393 | height: '12em'
394 | })
395 | container.append(elem)
396 |
397 | console.debug(1)
398 |
399 | $(document).ready(() ->
400 | console.debug(3)
401 | $('body').append(container)
402 | setTimeout((() ->
403 | cuteAlert('here')
404 | ), 1000)
405 | )
406 |
407 | console.debug(2)
408 |
409 | return (str) ->
410 | console.debug(4)
411 | elem.html(str)
412 | container.fadeIn()
413 | setTimeout((() ->
414 | container.fadeOut()
415 | ), 1000)
416 | )()
417 | ###
418 |
419 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the top of the
9 | * compiled file, but it's generally better to create a new file per style scope.
10 | *
11 | *= require_self
12 | *= require_tree .
13 | */
14 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/oauth.css.scss:
--------------------------------------------------------------------------------
1 | // Place all the styles related to the oauth controller here.
2 | // They will automatically be included in application.css.
3 | // You can use Sass (SCSS) here: http://sass-lang.com/
4 |
5 | $search-stack-width: 12em;
6 | $search-stack-margin-right: 1em;
7 | $main-chart-width: 40em;
8 | $bootstrap-blue: #0088CC;
9 | $bootstrap-blue-hover: #005580;
10 | $splitwise-green: #5BC5A7;
11 | $splitwise-green-hover: #E34234;
12 | $splitwise-gray: #676767;
13 |
14 | $standard-border-radius: 0.4em;
15 |
16 |
17 | body, input {
18 | font-family: "Lato", "Lucida Grande", "Verdana", "Helvetica";
19 | }
20 |
21 | #top-menu {
22 | position: relative;
23 | height: 5em;
24 | margin: 0;
25 | padding: 0 0 0 1em;
26 | list-style: none;
27 | border-bottom: 1px solid #ddd;
28 | white-space: nowrap;
29 | font-size: 1em;
30 |
31 | img {
32 | position: absolute;
33 |
34 | }
35 |
36 | #title {
37 | position: relative;
38 | top: 29px;
39 | margin: 0 1.5em 0 2.8em;
40 | color: #444;
41 | font-size: 1.7em;
42 |
43 | }
44 |
45 | .top-menu-item {
46 | display: inline-block;
47 | position: relative;
48 | top: 28px;
49 | margin-left: 0.2em;
50 | margin-bottom: -1px;
51 | border-top-left-radius: $standard-border-radius;
52 | border-top-right-radius: $standard-border-radius;
53 |
54 | a {
55 | padding: 1em 1em;
56 | color: $bootstrap-blue;
57 | text-decoration: none;
58 | display: block;
59 |
60 | }
61 |
62 | &:nth-child(2) {
63 | margin-left: 2em;
64 |
65 | }
66 |
67 | }
68 |
69 | .top-menu-item:not(.active) {
70 | &:hover {
71 | background: #EEEEEE;
72 | a {
73 | color: $bootstrap-blue-hover;
74 | }
75 | }
76 | }
77 |
78 | .top-menu-item.active {
79 | border-color: #ddd;
80 | border-style: solid;
81 | border-width: 1px;
82 | border-bottom-width: 3px;
83 | border-bottom-color: rgba(255, 255, 255, 1);
84 | background-color: #fff;
85 |
86 | a {
87 | color: #333333;
88 | cursor: default;
89 | }
90 | }
91 |
92 | .top-menu-item.logout {
93 | display: inline-block;
94 | position: absolute;
95 | right: 0;
96 | top: 29px; /* I hacked this to the proper height. */
97 | margin-right: 2em;
98 | }
99 |
100 | }
101 |
102 | #infoboxes-container {
103 | position: absolute;
104 | top: 72px; /* I hacked this to the proper height. */
105 |
106 | .infobox-container {
107 | visibility: hidden;
108 | opacity: 0;
109 | transition: visibility 0.3s, opacity 0.3s;
110 | position: absolute;
111 | z-index: 1;
112 | padding-top: 0.4em;
113 |
114 | &.show,
115 | &:hover, {
116 | visibility: visible;
117 | transition: opacity 0.5s 0.3s;
118 | opacity: 1;
119 |
120 | }
121 |
122 | .infobox {
123 | width: 12em;
124 | padding: 1em 1em 1em 1em;
125 | background-color: #fff;
126 | border: 1px solid #ddd;
127 | border-radius: $standard-border-radius;
128 | white-space: normal;
129 | box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.2);
130 |
131 | }
132 |
133 | }
134 |
135 | #matchbox {
136 | z-index: 2;
137 | margin-top: 0.4em;
138 | .input {
139 | display: inline-block;
140 | height: 21px;
141 | width: 8em;
142 | margin-right: 0.2em;
143 | font-size: 0.8em;
144 | border: 1px solid #ccc;
145 | padding: 0 0.5em;
146 | }
147 | .submit {
148 | background-color: #08C;
149 | display: inline-block;
150 | cursor: pointer;
151 | border-radius: 1em;
152 | color: #FFF;
153 | padding: 0.3em 0.8em;
154 | font-size: 0.8em;
155 |
156 | &:hover {
157 | background-color: #006FAA;
158 | color: #eee;
159 | }
160 |
161 | &:active {
162 | background-color: $bootstrap-blue-hover;
163 | color: #ccc;
164 |
165 | }
166 |
167 | }
168 |
169 | }
170 |
171 | }
172 |
173 | .well {
174 | background-color: #F5F5F5;
175 | box-shadow: inset 0 0 10px #CCC;
176 | }
177 |
178 | /* These styles pertain to the main chart: */
179 | #main-chart-container {
180 | padding: 0.4em;
181 | border: 1px solid #ccc;
182 | border-top-right-radius: $standard-border-radius;
183 | border-top-left-radius: $standard-border-radius;
184 | }
185 |
186 | #main-chart-container :last-child {
187 | border-bottom-right-radius: $standard-border-radius;
188 | border-bottom-left-radius: $standard-border-radius;
189 | }
190 |
191 | #main-chart {
192 | width: $main-chart-width - 0.8em;
193 | height: 30em;
194 | }
195 |
196 | /* These style pertain to the scroll chart: */
197 | #scroll-chart-container {
198 | border-bottom-right-radius: $standard-border-radius;
199 | border-bottom-left-radius: $standard-border-radius;
200 | padding: 1.5em 1.5em 1.5em 1.5em;
201 | }
202 |
203 | #scroll-chart {
204 | width: $main-chart-width - 3em;
205 | height: 4em;
206 | -moz-user-select: none;
207 | -khtml-user-select: none;
208 | -webkit-user-select: none;
209 | -o-user-select: none;
210 | }
211 |
212 | /* These styles pertain to the film: */
213 | div#film-backdrop {
214 | position: absolute;
215 | }
216 |
217 | div#film {
218 | background: rgba(0, 0, 0, 0.15);
219 | position: absolute;
220 | height: 100%;
221 | left: 80%;
222 | width: 20%;
223 | border-radius: $standard-border-radius;
224 | cursor: ew-resize;
225 |
226 | .film-handle.left {
227 | float: left;
228 | }
229 |
230 | .film-handle.right {
231 | float: right;
232 | }
233 |
234 | .film-handle {
235 | width: 0.4em;
236 | height: 100%;
237 | background: #666;
238 | border-radius: $standard-border-radius;
239 | cursor: col-resize;
240 |
241 | .film-label-container {
242 | position: absolute;
243 | top: 100%;
244 |
245 | .film-label {
246 | position: relative;
247 | left: -50%;
248 | -webkit-visibility: hidden;
249 | -moz-visibility: hidden;
250 | font-size: 12px;
251 | }
252 |
253 | }
254 |
255 | }
256 |
257 | }
258 |
259 |
260 | .floater {
261 | /* height: 2em;
262 | display: inline-block;
263 | font-family: "Lato", "Lucida Grande", "Verdana", "Helvetica";
264 | border-radius: $standard-border-radius/2;
265 | border-width: 1px;
266 | height: 1.5em;
267 | padding: 0 0.5em;*/
268 | display: inline-block;
269 | }
270 |
271 | /* These styles pertain to the matching chart: */
272 | #main-container-outer {
273 | position: relative;
274 | background-color: #fff;
275 | margin: 0;
276 | padding-top: 1em;
277 |
278 | }
279 |
280 | #main-container {
281 | margin: 0 auto;
282 | width: intrinsic;
283 | width: -moz-fit-content;
284 |
285 | #search-stack-container {
286 | float: left;
287 | width: $search-stack-width;
288 |
289 | }
290 |
291 | #matching-content-container {
292 | position: relative;
293 | margin-left: $search-stack-width + $search-stack-margin-right;
294 |
295 | }
296 |
297 | }
298 |
299 | /* These styles pertain to the search-stack: */
300 | #search-stack {
301 | position: relative; /* this may solve the issue of a list item's being too wide when given a width of 100% */
302 | list-style: none;
303 | border-radius: $standard-border-radius;
304 | border: 1px solid #ddd;
305 | margin: 0;
306 | padding: 0;
307 |
308 | .search-stack-item {
309 | background-color: transparent;
310 | border-width: 0;
311 | border-top: 1px solid #ddd;
312 | padding: 0;
313 | z-index: 1;
314 |
315 | &:not(.matchbox) {
316 | width: 100%;
317 |
318 | }
319 |
320 | &:first-child {
321 | border-top-right-radius: $standard-border-radius;
322 | border-top-left-radius: $standard-border-radius;
323 | border-top: none;
324 | }
325 |
326 | &:last-child {
327 | border-bottom-right-radius: $standard-border-radius;
328 | border-bottom-left-radius: $standard-border-radius;
329 | border-bottom: none;
330 | }
331 |
332 | &.matchbox {
333 | background-color: rgba(0, 136, 204, 1);
334 | padding: 0.5em 1em;
335 | z-index: 100;
336 |
337 | .input {
338 | width: 5em;
339 | font-size: 1em;
340 |
341 | }
342 |
343 | .submit {
344 | float: right;
345 | margin-top: 3px;
346 | padding: 0.05em 0.5em;
347 | border-radius: 1em;
348 | background-color: #fff;
349 | color: #000;
350 | font-size: 1em;
351 | height: 21px; /* for safari */
352 |
353 | }
354 | }
355 |
356 | .search-stack-item-content {
357 | padding: 0.5em 1em;
358 |
359 | .search-name {
360 | display: inline-block;
361 | width: 8em;
362 | overflow: hidden;
363 | text-overflow: ellipsis;
364 |
365 | }
366 |
367 | .delete-search {
368 | float: right;
369 | color: #DC0000;
370 |
371 | &:hover {
372 | cursor: pointer;
373 | text-decoration: underline;
374 |
375 | }
376 |
377 | }
378 |
379 | }
380 |
381 | }
382 |
383 | }
384 |
385 | /* These styles pertain to the loading film: */
386 | #loading-film {
387 | visibility: hidden;
388 | opacity: 0;
389 | transition: visibility 0.2s, opacity 0.2s;
390 | position: absolute;
391 | top: 0;
392 | right: 0;
393 | bottom: 0;
394 | left: 0;
395 | z-index: 1;
396 | border-radius: $standard-border-radius;
397 | background-color: rgba(0, 0, 0, 0.2);
398 |
399 | /* This will stop the user from selecting the film: */
400 | -webkit-touch-callout: none;
401 | -webkit-user-select: none;
402 | -khtml-user-select: none;
403 | -moz-user-select: none;
404 | -ms-user-select: none;
405 | user-select: none;
406 |
407 |
408 | &.show {
409 | visibility: visible;
410 | opacity: 1;
411 | transition: opacity 0.2s;
412 |
413 | }
414 |
415 | #loading-film-message {
416 | position: absolute;
417 | top: 0;
418 | right: 0;
419 | bottom: 0;
420 | left: 0;
421 | width: 6em; /* I will change this later to fit the content. */
422 | height: 1em;
423 | margin: auto;
424 |
425 | img {
426 | position: absolute;
427 | top: 0;
428 | left: 0;
429 |
430 | }
431 |
432 | }
433 |
434 | }
435 |
436 | /* These styles pertain to the welcome page: */
437 | .welcome#content-container {
438 |
439 | #content {
440 | position: relative;
441 | width: -moz-fit-content;
442 | width: intrinsic;
443 | left: 0;
444 | right: 0;
445 | margin: 0 auto;
446 | white-space: nowrap;
447 |
448 | #main-text-container {
449 | display: inline-block;
450 | vertical-align: top;
451 | margin-top: 14em;
452 | white-space: normal;
453 |
454 | #main-text {
455 | width: 20em;
456 | text-align: left;
457 | padding: 0 0 0 2em;
458 |
459 | p.elaboration {
460 | font-size: 0.7em;
461 | color: #676767;
462 |
463 | }
464 |
465 | a.authorize {
466 | $margin-top: 1em;
467 | $press-displacement: 0.25em;
468 | $side-height: 0.4em;
469 | $under-shadow-color: rgba(0, 0, 0, 0.5);
470 |
471 | display: inline-block;
472 | margin-top: $margin-top;
473 | padding: 0.5em 1em;
474 | border-radius: 1em;
475 | background-color: #5BC5A7;
476 | color: #FFF;
477 | text-decoration: none;
478 | text-align: center;
479 | box-shadow: 0 $side-height 0 0 #676767,
480 | 0 $side-height 6px 2px $under-shadow-color;
481 | text-shadow: 0 -1px 0 #44947D;
482 | border: 1px solid #5BC5A7;
483 |
484 | &:hover {
485 | box-shadow: 0 $side-height 0 0 #676767,
486 | 0 $side-height 6px 2px $under-shadow-color,
487 | inset 0 0 1em 0 rgba(255, 255, 255, 0.7);
488 |
489 | }
490 |
491 | &:active {
492 | box-shadow: 0 $side-height - $press-displacement 0 0 #676767,
493 | 0 $side-height - $press-displacement 6px 0px $under-shadow-color,
494 | inset 0 0 10px 0 #FFF;
495 | margin-top: $margin-top + $press-displacement;
496 |
497 | }
498 | }
499 |
500 | }
501 |
502 | }
503 |
504 | #main-image-container {
505 | display: inline-block;
506 | vertical-align: middle;
507 | /*-moz-transform: scale(0.6, 0.6); */
508 |
509 | #main-image {
510 | /*width: 60%;*/
511 | /*matrix(1, 1, 1, 1, 0, 0); for some reason this doesn't work ...*/
512 | }
513 |
514 | }
515 |
516 | }
517 |
518 | }
519 |
520 |
521 |
522 |
523 |
524 |
525 |
526 |
527 |
528 |
529 |
530 |
531 |
532 |
533 |
534 |
535 |
536 |
537 |
538 |
539 |
540 |
541 |
542 |
543 |
544 |
545 |
546 |
547 |
548 |
549 |
550 |
551 |
552 |
553 |
554 |
555 |
556 |
557 |
558 |
559 |
560 |
561 | /*
562 | #matchbox-container {
563 | background-color: #FFF;
564 | border-radius: 1em;
565 | position: absolute;
566 | top: 0;
567 | left: 100%;
568 | visibility: hidden;
569 | z-index: 2;
570 | -webkit-transform: translate3d(0px, 0px, 0px);
571 | box-shadow: 0.2em 0.2em 0.5em 0.2em rgba(0, 0, 0, 0.4);
572 | width: 16em;
573 | height: 35px;
574 |
575 | #matchbox-nub {
576 | background-color: #FFF;
577 | box-shadow: -0.3em -0.25em 0.6em 0 rgba(0, 0, 0, 0.2);
578 | position: absolute;
579 | -webkit-transform: matrix(0.3535533905932738, -0.3535533905932738, 0.3535533905932738, 0.3535533905932738, -13.3, 0);
580 | -moz-transform: matrix(0.3535533905932738, -0.3535533905932738, 0.3535533905932738, 0.3535533905932738, -13.3, 0);
581 | -ms-transform: matrix(0.3535533905932738, -0.3535533905932738, 0.3535533905932738, 0.3535533905932738, -13.3, 0);
582 | z-index: -1;
583 | width: 35px;
584 | height: 35px;
585 | }
586 |
587 | #matchbox {
588 | position: absolute;
589 | top: 0;
590 | bottom: 0;
591 | right: 0;
592 | margin: auto 0.4em;
593 | z-index: 2;
594 | height: 23px;
595 | .input {
596 | height: 21px;
597 | width: 12em;
598 | display: inline-block;
599 | font-size: 0.8em;
600 | border: 1px solid #ccc;
601 | padding: 0 0.5em;
602 | }
603 | .submit {
604 | background-color: #08C;
605 | display: inline-block;
606 | cursor: pointer;
607 | border-radius: 1em;
608 | color: #FFF;
609 | padding: 0.3em 0.8em;
610 | font-size: 0.8em;
611 |
612 | &:hover {
613 | background-color: #006FAA;
614 | color: #eee;
615 | }
616 |
617 | &:active {
618 | background-color: $bootstrap-blue-hover;
619 | color: #ccc;
620 | }
621 | }
622 | }
623 | }
624 | */
625 |
--------------------------------------------------------------------------------
/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | protect_from_forgery
3 | end
4 |
--------------------------------------------------------------------------------
/app/controllers/user_controller.rb:
--------------------------------------------------------------------------------
1 | class UserController < ApplicationController
2 | before_filter :check_for_credentials, except: [:login, :callback, :welcome]
3 |
4 | def check_for_credentials
5 | unless access_token
6 | redirect_to login_path
7 | end
8 | end
9 |
10 | def login
11 | puts "here"
12 | p consumer
13 | puts consumer
14 | @request_token = consumer.get_request_token
15 | p @request_token
16 | puts @request_token
17 | Rails.cache.write(@request_token.token, @request_token.secret)
18 | p @request_token.authorize_url
19 | puts @request_token.authorize_url
20 | redirect_to @request_token.authorize_url
21 | end
22 |
23 | def callback
24 | request_token = OAuth::RequestToken.new(consumer, params[:oauth_token], Rails.cache.read(params[:oauth_token]))
25 | access_token = request_token.get_access_token(:oauth_verifier => params[:oauth_verifier])
26 | session[:access_token] = access_token.token
27 | session[:access_token_secret] = access_token.secret
28 | after_callback
29 | rescue
30 | render :text => "Looks like something went wrong - sorry!"
31 | end
32 |
33 | def after_callback
34 | redirect_to action: 'balance_over_time'
35 | end
36 |
37 | def logout
38 | reset_session
39 | after_logout
40 | end
41 |
42 | def after_logout
43 | redirect_to action: 'welcome'
44 | end
45 |
46 | # Actions with views
47 | def welcome
48 | if access_token
49 | after_callback
50 | end
51 | end
52 |
53 | def balance_over_time
54 | @title = "Balance"
55 | @data = JSON.unparse(current_user.get_balance_over_time)
56 | end
57 |
58 | def balances_over_time_with_friends
59 | @title = "Balance with friends"
60 | @data = JSON.unparse(current_user.get_balances_over_time_with_friends)
61 | end
62 |
63 | def expenses_over_time
64 | @title = "Expenses"
65 | @data = JSON.unparse(current_user.get_expenses_over_time_by_month)
66 | end
67 |
68 | def expenses_by_category
69 | @title = "Expenses by category"
70 | @data = JSON.unparse(current_user.get_expenses_by_category)
71 | end
72 |
73 | def expenses_by_category_over_time
74 | @title = "Category history"
75 | @data = JSON.unparse(current_user.get_expenses_by_category_over_time_cumulative)
76 | end
77 |
78 | def expenses_matching
79 | @title = "Search an expense"
80 | #@data = JSON.unparse(current_user.get_expenses_matching_cumulative(params[:query]))
81 | end
82 |
83 | def get_expenses_matching
84 | render text: JSON.unparse(current_user.get_expenses_matching_cumulative(params[:query]))
85 | end
86 |
87 | private
88 |
89 | def consumer
90 | @consumer ||= OAuth::Consumer.new(ENV["SPLITWISE_API_KEY"], ENV["SPLITWISE_API_SECRET"], {
91 | :site => ENV["SPLITWISE_SITE"],
92 | :scheme => :header,
93 | :http_method => :post,
94 | :authorize_path => ENV["SPLITWISE_AUTHORIZE_URL"],
95 | :request_token_path => ENV["SPLITWISE_REQUEST_TOKEN_URL"],
96 | :access_token_path => ENV["SPLITWISE_ACCESS_TOKEN_URL"]
97 | })
98 | end
99 |
100 | def access_token
101 | if session[:access_token]
102 | @access_token ||= OAuth::AccessToken.new(consumer, session[:access_token], session[:access_token_secret])
103 | end
104 | end
105 |
106 | def current_user
107 | @current_user ||= User.new(access_token)
108 | end
109 | end
110 |
111 | =begin
112 | def get_balance_over_time
113 | if params[:format] == 'google-charts'
114 | render text: JSON.unparse(User.new(session[:access_token]).get_balance_over_time_google_charts_format)
115 | else
116 | render text: JSON.unparse(User.new(session[:access_token]).get_balance_over_time)
117 | end
118 | end
119 |
120 | def get_balances_over_time_with_friends
121 | render text: JSON.unparse(User.new(session[:access_token]).get_balances_over_time_with_friends)
122 | end
123 |
124 | def get_expenses_over_time
125 | render text: JSON.unparse(User.new(session[:access_token]).get_expenses_over_time)
126 | end
127 |
128 | def get_expenses_over_time_cumulative
129 | render text: JSON.unparse(User.new(session[:access_token]).get_expenses_over_time_cumulative)
130 | end
131 |
132 | def get_expenses_by_category
133 | render text: JSON.unparse(User.new(session[:access_token]).get_expenses_by_category)
134 | end
135 |
136 | def get_expenses_matching
137 | render text: JSON.unparse(User.new(session[:access_token]).get_expenses_matching(params[:query]))
138 | end
139 | =end
140 |
--------------------------------------------------------------------------------
/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/oauth_helper.rb:
--------------------------------------------------------------------------------
1 | module OauthHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/mailers/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/mailers/.gitkeep
--------------------------------------------------------------------------------
/app/models/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/models/.gitkeep
--------------------------------------------------------------------------------
/app/models/user.rb:
--------------------------------------------------------------------------------
1 | require 'time'
2 |
3 | class Array
4 | def sortBy! &iterator
5 | self.sort! do |a, b|
6 | iterator.call(a) <=> iterator.call(b)
7 | end
8 | end
9 | end
10 |
11 | class User
12 |
13 | API_URL = 'https://secure.splitwise.com/api/v3.0/'
14 |
15 | def initialize access_token
16 | @access_token = access_token
17 | end
18 |
19 | ['get_current_user', 'get_friends'].each do |method|
20 | define_method method.to_sym do
21 | data = @access_token.get(API_URL+method)
22 | body = data.body
23 | parsed = JSON.parse(body)
24 | parsed
25 | end
26 | end
27 |
28 | def get_expenses
29 | JSON.parse(@access_token.get(API_URL+'get_expenses?limit=250&visible=true').body)
30 | end
31 |
32 | def get_current_user_id
33 | @id or (@id = get_current_user['user']['id'])
34 | end
35 |
36 | def get_friend_ids
37 | id = get_current_user_id
38 | friend_ids = []
39 | each_friend do |friend|
40 | friend_ids.push(friend['id'])
41 | end
42 | end
43 |
44 | =begin
45 | def get_friend_ids
46 | id = get_current_user_id
47 | friend_ids = []
48 | each_friend do |friend|
49 | candidates = friend['users'].reject{|u| u['id'] == id}
50 | throw "I find not 1 but #{candidates.length} other users in a friend." unless candidates.length == 1
51 | friend_ids.push(candidates[0]['id'])
52 | end
53 | end
54 | =end
55 |
56 | def each_expense &block
57 | expenses = get_expenses['expenses']
58 | expenses.sortBy! do |expense|
59 | expense['date']
60 | end
61 | expenses.each &block
62 | end
63 |
64 | def each_expense_newest_to_oldest &block
65 | expenses = get_expenses['expenses']
66 | expenses.sort! do |a, b|
67 | b['date'] <=> a['date']
68 | end
69 | expenses.each &block
70 | end
71 |
72 | def each_expense_and_share &block
73 | id = get_current_user_id
74 | expenses = get_expenses['expenses']
75 | expenses.sortBy! do |expense|
76 | expense['date']
77 | end
78 | expenses.collect! do |expense|
79 | users = expense['users'].select do |share|
80 | next (share['user'] and share['user']['id'] == id)
81 | end
82 | if users.length == 1
83 | share = users[0]
84 | block.call(expense, share)
85 | else
86 | # TODO: handle expenses not involving the current user
87 | # throw "Found not one but #{users.length} users!"
88 | end
89 | end
90 | end
91 |
92 | def each_expense_and_share_newest_to_oldest &block
93 | id = get_current_user_id
94 | expenses = get_expenses['expenses']
95 | expenses.sort! do |a, b|
96 | b['date'] <=> a['date']
97 | end
98 | expenses.collect! do |expense|
99 | users = expense['users'].select do |share|
100 | next (share['user'] and share['user']['id'] == id)
101 | end
102 | if users.length == 1
103 | share = users[0]
104 | block.call(expense, share)
105 | else
106 | # TODO: handle expenses not involving the current user
107 | # throw "Found not one but #{users.length} users!"
108 | end
109 | end
110 | end
111 |
112 | def each_friend &block
113 | get_friends['friends'].each &block
114 | end
115 |
116 | =begin
117 | def each_friend &block
118 | id = get_current_user_id
119 | each_friend do |friend|
120 | candidates = friend['users'].reject{|u| u['id'] == id}
121 | throw "I find not 1 but #{candidates.length} other users in a friend." unless candidates.length == 1
122 | block.call(candidates[0])
123 | end
124 | end
125 | =end
126 |
127 | def array_to_usd_balance arr
128 | arr.inject 0 do |rest, b|
129 | if b['currency_code'].downcase == 'usd'
130 | return rest + b['amount'].to_f
131 | else
132 | return rest
133 | end
134 | end
135 | end
136 |
137 | def get_current_balance
138 | balance = 0
139 | each_friend do |friend|
140 | balance += array_to_usd_balance friend['balance']
141 | end
142 | balance
143 | end
144 |
145 | def get_balance_over_time
146 | balance = get_current_balance
147 | balances = []
148 | each_expense_and_share_newest_to_oldest do |expense, share|
149 | balances.push({'date' => expense['date'], 'balance' => balance.to_f})
150 | balance -= share['net_balance'].to_f
151 | end
152 | return balances.reverse
153 | end
154 |
155 | def get_current_balances_with_friends
156 | id = get_current_user_id
157 | d = get_current_user_id
158 | friends = Hash.new(-1)
159 | each_friend do |friend|
160 | friends[friend['id']] = array_to_usd_balance friend['balance']
161 | end
162 | friends
163 | end
164 |
165 | def get_balances_over_time_with_friends
166 | id = get_current_user_id
167 | current_balances = get_current_balances_with_friends
168 | friend_keys = current_balances.keys.sort!
169 | balance_records = []
170 | each_expense_newest_to_oldest do |expense|
171 | balance_records.push({
172 | 'date' => expense['date'],
173 | 'balances' => current_balances.values_at(*friend_keys)
174 | })
175 | expense['repayments'].each do |repayment|
176 | if repayment['from'] == id
177 | current_balances[repayment['to']] += repayment['amount'].to_f
178 | elsif repayment['to'] == id
179 | current_balances[repayment['from']] -= repayment['amount'].to_f
180 | end
181 | end
182 | end
183 | friends = []
184 | each_friend do |friend|
185 | friends.push(friend)
186 | end
187 | {
188 | 'friends' => friends,
189 | 'balances' => balance_records.reverse
190 | }
191 | end
192 |
193 | def get_expenses_over_time
194 | expenses = []
195 | each_expense_and_share do |expense, share|
196 | unless expense['payment']
197 | expenses.push({
198 | "date" => expense['date'],
199 | "expense" => share['owed_share'].to_f,
200 | "description" => expense['description']
201 | })
202 | end
203 | end
204 | expenses.sortBy! do |a|
205 | a['date']
206 | end
207 | expenses
208 | end
209 |
210 | def bucket_of date
211 | if date.is_a? String
212 | date = Time.parse(date)
213 | end
214 | Time.gm(date.year, date.month)
215 | end
216 |
217 | def same_bucket d1, d2
218 | (bucket_of d1) == (bucket_of d2)
219 | end
220 |
221 | def get_expenses_over_time_by_month
222 | x = 0
223 | get_expenses_over_time.inject [] do |rest, expense|
224 | if rest[-1] and same_bucket rest[-1]['date'], expense['date']
225 | rest[-1]['expense'] += expense['expense']
226 | rest[-1]['description'] = expense['description']
227 | rest
228 | else
229 | expense['date'] = bucket_of expense['date']
230 | rest.push expense
231 | end
232 | end
233 | end
234 |
235 | def get_expenses_over_time_cumulative
236 | total = 0
237 | expenses = get_expenses_over_time
238 | expenses.each do |e|
239 | e['total'] = total = e['expense'] + total
240 | end
241 | expenses
242 | end
243 |
244 | def get_expenses_by_category
245 | categoryHash = {}
246 | each_expense_and_share do |expense, share|
247 | unless expense['payment']
248 | categoryHash[expense['category']['name']] ||= 0
249 | categoryHash[expense['category']['name']] += share['owed_share'].to_f
250 | end
251 | end
252 | categoryHash
253 | end
254 |
255 | def get_expenses_by_category_over_time_cumulative
256 | id = get_current_user_id
257 | current_expenses = get_expenses_by_category
258 | categories = current_expenses.keys.sortBy! do |category|
259 | -current_expenses[category]
260 | end
261 | expenses_records = []
262 | each_expense_and_share_newest_to_oldest do |expense, share|
263 | unless expense['payment']
264 | expenses_records.push({
265 | 'date' => expense['date'],
266 | 'expenses' => current_expenses.values_at(*categories)
267 | })
268 | current_expenses[expense['category']['name']] -= share['owed_share'].to_f
269 | end
270 | end
271 | {
272 | 'categories' => categories,
273 | 'expenses' => expenses_records.reverse
274 | }
275 | end
276 |
277 | def get_expenses_matching query
278 | expenses = []
279 | processed_query = query.gsub(/[^a-zA-Z]/, ' ').split(/\ +/).map(&:downcase)
280 | p query
281 | p processed_query
282 | each_expense_and_share do |expense, share|
283 | unless expense['payment']
284 | if processed_query.select { |q|
285 | (expense['description'] + ' ' + expense['category']['name']).downcase.match(q)
286 | }.length > 0 or processed_query.length == 0
287 | expenses.push({
288 | "date" => expense['date'],
289 | "expense" => share['owed_share'].to_f,
290 | "description" => expense['description']
291 | })
292 | end
293 | end
294 | end
295 | expenses.sort! do |a, b|
296 | a['date'] <=> b['date']
297 | end
298 | expenses
299 | end
300 |
301 | def get_expenses_matching_cumulative query # returns expenses in the form {'date' => ..., 'description' => ..., 'expense' => ..., 'total' => ...}
302 | total = 0
303 | expenses = get_expenses_matching query
304 | expenses.each do |e|
305 | e['total'] = total = e['expense'] + total
306 | end
307 | expenses
308 | end
309 | end
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 | #No longer useful:
345 |
346 | =begin
347 | def self.get_net_balances_over_time access_token
348 | expenses = []
349 | each_expense_and_share do |expense, share|
350 | expenses.push({
351 | "date" => expense['date'],
352 | "net_balance" => share['net_balance']
353 | })
354 | end
355 | expenses
356 | end
357 | =end
358 |
359 | =begin
360 | def get_balances_with_friends
361 | id = get_current_user_id
362 | friend_ids = []
363 | friend_id_to_name = {}
364 | balances = {}
365 | each_expense do |expense|
366 | if balances[expense['date']]
367 | #throw "I find a date in balances already set!"
368 | end
369 | balances[expense['date']] = []
370 | expense['repayments'].each do |repayment|
371 | if repayment['from'] == id
372 | index = friend_ids.index(repayment['to'])
373 | unless index
374 | index = friend_ids.push(repayment['to']).length - 1
375 | friend_id_to_name[friend_ids[index]] = expense['users'].select do |user|
376 | user['user_id'] == friend_ids[index]
377 | end.first['user']
378 | end
379 | balances[expense['date']][index] ||= 0
380 | balances[expense['date']][index] -= repayment['amount'].to_f
381 | elsif repayment['to'] == id
382 | index = friend_ids.index(repayment['from'])
383 | unless index
384 | index = friend_ids.push(repayment['from']).length - 1
385 | friend_id_to_name[friend_ids[index]] = expense['users'].select do |user|
386 | user['user_id'] == friend_ids[index]
387 | end.first['user']
388 | end
389 | balances[expense['date']][index] ||= 0
390 | balances[expense['date']][index] += repayment['amount'].to_f
391 | end
392 | end
393 | end
394 | friends = friend_ids.collect do |id|
395 | friend_id_to_name[id]
396 | end
397 | balances.each do |date, bs|
398 | new_bs = []
399 | friend_ids.each_index do |i|
400 | new_bs[i] = (bs[i] or 0)
401 | end
402 | balances[date] = new_bs
403 | end
404 | {
405 | 'friends' => friends,
406 | 'balances' => balances
407 | }
408 | end
409 |
410 | def get_balances_over_time_with_friends
411 | d = get_balances_with_friends
412 | friends = d['friends']
413 | balanceses = d['balances'].to_a.sortBy! { |key, vals| key }
414 | cumulative_balances = friends.collect { 0 }
415 | balanceses.collect! do |date, balances|
416 | cumulative_balances = cumulative_balances.zip(balances).map { |a, b| a + b }
417 | [date, cumulative_balances.dup]
418 | end
419 | {
420 | 'friends' => friends,
421 | 'balances' => balanceses
422 | }
423 | end
424 | =end
425 |
426 | =begin
427 | def get_expenses_by_category_over_time #Exports in the form {categories: ["foo", "bar", "baz"], rows: [["10:11:12 Jan 6", 13, 84, 29], ...]}
428 | categoryHash = {}
429 | rows = []
430 | each_expense_and_share do |expense, share|
431 | unless expense['payment']
432 | rows.push({
433 | 'date' => expense['date'],
434 | expense['category']['name'] => share['owed_share']
435 | })
436 | categoryHash[expense['category']['name']] = true
437 | end
438 | end
439 | categories = categoryHash.keys
440 | rows.collect! do |row|
441 | next {
442 | 'date' => row['date'],
443 | 'expenses' => categories.collect{ |category| row[category] }
444 | }
445 | end
446 | {
447 | 'categories' => categoryHash.keys,
448 | 'rows' => rows
449 | }
450 | end
451 | =end
452 |
453 |
454 | =begin
455 | def purge_deleted_friends hash #NB: this modifies the hash.
456 | hash.delete_if do |key, _|
457 | key == -1
458 | end
459 | end
460 |
461 | private :purge_deleted_friends
462 | =end
--------------------------------------------------------------------------------
/app/views/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/views/.DS_Store
--------------------------------------------------------------------------------
/app/views/layouts/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/app/views/layouts/.DS_Store
--------------------------------------------------------------------------------
/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Api Example<%= " \u00B7 #{@title}" if @title %>
5 |
6 | <%= stylesheet_link_tag "application", :media => "all" %>
7 | <%= javascript_include_tag "application" %>
8 | <%= csrf_meta_tags %>
9 |
10 |
11 |
12 |
13 |
14 | <%= yield %>
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/views/user/_balance_side_menu.html.erb:
--------------------------------------------------------------------------------
1 | <%=
2 |
3 | menu_items = [['total', 'Total', '/user/balance_over_time'],
4 | ['with-friends', 'With Friends', '/user/balances_over_time_with_friends']
5 | ].map do |id, name, link|
6 | result = {id: id, name: name, link: link}
7 | if balance_side_menu.to_s == id
8 | result[:active] = true
9 | end
10 | result
11 | end
12 |
13 | render partial: 'user/side_menu', object: menu_items
14 |
15 | %>
--------------------------------------------------------------------------------
/app/views/user/_create_charts.html.erb:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/app/views/user/_expenses_side_menu.html.erb:
--------------------------------------------------------------------------------
1 | <%=
2 |
3 | menu_items = [['total', 'Total', '/user/expenses_over_time'],
4 | ['by-category', 'By Category', '/user/expenses_by_category'],
5 | ['by-category-over-time', 'Category History', '/user/expenses_by_category_over_time'],
6 | ['matching', 'Search an expense', '#']
7 | ].map do |id, name, link|
8 | result = {id: id, name: name, link: link}
9 | if expenses_side_menu.to_s == id
10 | result[:active] = true
11 | end
12 | result
13 | end
14 |
15 | render partial: 'user/side_menu', object: menu_items
16 |
17 | %>
--------------------------------------------------------------------------------
/app/views/user/_side_menu.html.erb:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/app/views/user/_top_menu.html.erb:
--------------------------------------------------------------------------------
1 | <%
2 | data = [
3 | ['balance', 'Balance', '/user/balance_over_time', 'Trace your balance over time.'],
4 | ['with-friends', 'Friends', '/user/balances_over_time_with_friends', 'Track your balance with each of your friends.'],
5 | ['expenses', 'Expenses', '/user/expenses_over_time', 'See the cumulative graph of everything you\'ve spent.'],
6 | ['by-category', 'Categories', '/user/expenses_by_category', 'Break down your spending into categories.'],
7 | ['by-category-over-time', 'Category History', '/user/expenses_by_category_over_time', 'See how your spending in different categories has changed over time.'],
8 | ['matching', 'Search', '/user/expenses_matching', 'Search for expenses. ']
9 | ]
10 | %>
11 |
12 |
26 |
27 |
45 |
--------------------------------------------------------------------------------
/app/views/user/balance_over_time.html.erb:
--------------------------------------------------------------------------------
1 | <%= render partial: 'user/top_menu', object: :balance %>
2 | <%= javascript_include_tag "oauth_balance" %>
3 | <%= render partial: 'create_charts' %>
4 |
19 |
--------------------------------------------------------------------------------
/app/views/user/balances_over_time_with_friends.html.erb:
--------------------------------------------------------------------------------
1 | <%= render partial: 'user/top_menu', object: :'with-friends' %>
2 | <%= javascript_include_tag "oauth_balances_over_time_with_friends" %>
3 | <%= render partial: 'create_charts' %>
4 |
--------------------------------------------------------------------------------
/app/views/user/expenses_by_category.html.erb:
--------------------------------------------------------------------------------
1 | <%= render partial: 'user/top_menu', object: :'by-category' %>
2 | <%= javascript_include_tag "oauth_expenses_by_category" %>
3 | <%= render partial: 'create_charts' %>
4 |
16 |
17 |
--------------------------------------------------------------------------------
/app/views/user/expenses_by_category_over_time.html.erb:
--------------------------------------------------------------------------------
1 | <%= render partial: 'user/top_menu', object: :'by-category-over-time' %>
2 | <%= javascript_include_tag "oauth_expenses_by_category_over_time" %>
3 | <%= render partial: 'create_charts' %>
4 |
19 |
--------------------------------------------------------------------------------
/app/views/user/expenses_matching.html.erb:
--------------------------------------------------------------------------------
1 | <%= javascript_include_tag "oauth_expenses_matching" %>
2 | <%= render partial: 'create_charts' %>
3 | <%= render partial: 'user/top_menu', object: :matching %>
4 |
5 |
6 |
10 |
11 |
12 |
13 |
14 |
15 | Search
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Loading
25 |
26 |
27 |
30 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/app/views/user/expenses_over_time.html.erb:
--------------------------------------------------------------------------------
1 | <%= render partial: 'user/top_menu', object: :expenses %>
2 | <%= javascript_include_tag "oauth_expenses" %>
3 | <%= render partial: 'create_charts' %>
4 |
19 |
--------------------------------------------------------------------------------
/app/views/user/welcome.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | <%= image_tag "splitwise-logo-large.png" %>
6 |
7 |
8 |
9 |
10 |
API Example
11 |
Analyze your Splitwise.
12 |
API Example helps you keep track of your expenses and balance by presenting data from your account in a series of concise, readable, and interactive graphics.
13 |
Authorize
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require ::File.expand_path('../config/environment', __FILE__)
4 | run ApiExample::Application
5 |
--------------------------------------------------------------------------------
/config/application.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path('../boot', __FILE__)
2 |
3 | require 'rails/all'
4 |
5 | if defined?(Bundler)
6 | # If you precompile assets before deploying to production, use this line
7 | Bundler.require(*Rails.groups(:assets => %w(development test)))
8 | # If you want your assets lazily compiled in production, use this line
9 | # Bundler.require(:default, :assets, Rails.env)
10 | end
11 |
12 | module ApiExample
13 | class Application < Rails::Application
14 | # Settings in config/environments/* take precedence over those specified here.
15 | # Application configuration should go into files in config/initializers
16 | # -- all .rb files in that directory are automatically loaded.
17 |
18 | # Custom directories with classes and modules you want to be autoloadable.
19 | # config.autoload_paths += %W(#{config.root}/extras)
20 |
21 | # Only load the plugins named here, in the order given (default is alphabetical).
22 | # :all can be used as a placeholder for all plugins not explicitly named.
23 | # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
24 |
25 | # Activate observers that should always be running.
26 | # config.active_record.observers = :cacher, :garbage_collector, :forum_observer
27 |
28 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
29 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
30 | # config.time_zone = 'Central Time (US & Canada)'
31 |
32 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
33 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
34 | # config.i18n.default_locale = :de
35 |
36 | # Configure the default encoding used in templates for Ruby 1.9.
37 | config.encoding = "utf-8"
38 |
39 | # Configure sensitive parameters which will be filtered from the log file.
40 | config.filter_parameters += [:password]
41 |
42 | # Enable escaping HTML in JSON.
43 | config.active_support.escape_html_entities_in_json = true
44 |
45 | # Use SQL instead of Active Record's schema dumper when creating the database.
46 | # This is necessary if your schema can't be completely dumped by the schema dumper,
47 | # like if you have constraints or database-specific column types
48 | # config.active_record.schema_format = :sql
49 |
50 | # Enforce whitelist mode for mass assignment.
51 | # This will create an empty whitelist of attributes available for mass-assignment for all models
52 | # in your app. As such, your models will need to explicitly whitelist or blacklist accessible
53 | # parameters by using an attr_accessible or attr_protected declaration.
54 | config.active_record.whitelist_attributes = true
55 |
56 | # Enable the asset pipeline
57 | config.assets.enabled = true
58 |
59 | # Version of your assets, change this if you want to expire all your assets
60 | config.assets.version = '1.0'
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/config/application.yml:
--------------------------------------------------------------------------------
1 | # Add application configuration variables here, as shown below.
2 |
3 | SPLITWISE_API_KEY: TjqN1wJ1TDr3iDjCjKsAulvPuh2Wct5eJlBroIus
4 | SPLITWISE_API_SECRET: Bv2AVwZJNhElF5oqYYlx619B0JxsaONxy85HYaGt
5 | SPLITWISE_SITE: "https://secure.splitwise.com"
6 | SPLITWISE_AUTHORIZE_URL: "/authorize"
7 | SPLITWISE_REQUEST_TOKEN_URL: "/api/v3.0/get_request_token"
8 | SPLITWISE_ACCESS_TOKEN_URL: "/api/v3.0/get_access_token"
--------------------------------------------------------------------------------
/config/application.yml.EXAMPLE:
--------------------------------------------------------------------------------
1 | # Add application configuration variables here, as shown below.
2 |
3 | SPLITWISE_API_KEY: your_key_here
4 | SPLITWISE_API_SECRET: your_secret_here
5 | SPLITWISE_SITE: "https://secure.splitwise.com"
6 | SPLITWISE_AUTHORIZE_URL: "/authorize"
7 | SPLITWISE_REQUEST_TOKEN_URL: "/api/v3.0/get_request_token"
8 | SPLITWISE_ACCESS_TOKEN_URL: "/api/v3.0/get_access_token"
--------------------------------------------------------------------------------
/config/boot.rb:
--------------------------------------------------------------------------------
1 | require 'rubygems'
2 |
3 | # Set up gems listed in the Gemfile.
4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
5 |
6 | require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE'])
7 |
--------------------------------------------------------------------------------
/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite version 3.x
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem 'sqlite3'
6 | development:
7 | adapter: sqlite3
8 | database: db/development.sqlite3
9 | pool: 5
10 | timeout: 5000
11 |
12 | # Warning: The database defined as "test" will be erased and
13 | # re-generated from your development database when you run "rake".
14 | # Do not set this db to the same as development or production.
15 | test:
16 | adapter: sqlite3
17 | database: db/test.sqlite3
18 | pool: 5
19 | timeout: 5000
20 |
21 | production:
22 | adapter: sqlite3
23 | database: db/production.sqlite3
24 | pool: 5
25 | timeout: 5000
26 |
--------------------------------------------------------------------------------
/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the rails application
2 | require File.expand_path('../application', __FILE__)
3 |
4 | # Initialize the rails application
5 | ApiExample::Application.initialize!
6 |
--------------------------------------------------------------------------------
/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | ApiExample::Application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb
3 |
4 | # In the development environment your application's code is reloaded on
5 | # every request. This slows down response time but is perfect for development
6 | # since you don't have to restart the web server when you make code changes.
7 | config.cache_classes = false
8 |
9 | # Log error messages when you accidentally call methods on nil.
10 | config.whiny_nils = true
11 |
12 | # Show full error reports and disable caching
13 | config.consider_all_requests_local = true
14 | config.action_controller.perform_caching = false
15 |
16 | # Don't care if the mailer can't send
17 | config.action_mailer.raise_delivery_errors = false
18 |
19 | # Print deprecation notices to the Rails logger
20 | config.active_support.deprecation = :log
21 |
22 | # Only use best-standards-support built into browsers
23 | config.action_dispatch.best_standards_support = :builtin
24 |
25 | # Raise exception on mass assignment protection for Active Record models
26 | config.active_record.mass_assignment_sanitizer = :strict
27 |
28 | # Log the query plan for queries taking more than this (works
29 | # with SQLite, MySQL, and PostgreSQL)
30 | config.active_record.auto_explain_threshold_in_seconds = 0.5
31 |
32 | # Do not compress assets
33 | config.assets.compress = false
34 |
35 | # Expands the lines which load the assets
36 | config.assets.debug = true
37 | end
38 |
--------------------------------------------------------------------------------
/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | ApiExample::Application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb
3 |
4 | # Code is not reloaded between requests
5 | config.cache_classes = true
6 |
7 | # Full error reports are disabled and caching is turned on
8 | config.consider_all_requests_local = false
9 | config.action_controller.perform_caching = true
10 |
11 | # Disable Rails's static asset server (Apache or nginx will already do this)
12 | config.serve_static_assets = false
13 |
14 | # Compress JavaScripts and CSS
15 | config.assets.compress = true
16 |
17 | # Don't fallback to assets pipeline if a precompiled asset is missed
18 | config.assets.compile = false
19 |
20 | # Generate digests for assets URLs
21 | config.assets.digest = true
22 |
23 | # Defaults to nil and saved in location specified by config.assets.prefix
24 | # config.assets.manifest = YOUR_PATH
25 |
26 | # Specifies the header that your server uses for sending files
27 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache
28 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx
29 |
30 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
31 | # config.force_ssl = true
32 |
33 | # See everything in the log (default is :info)
34 | # config.log_level = :debug
35 |
36 | # Prepend all log lines with the following tags
37 | # config.log_tags = [ :subdomain, :uuid ]
38 |
39 | # Use a different logger for distributed setups
40 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
41 |
42 | # Use a different cache store in production
43 | # config.cache_store = :mem_cache_store
44 |
45 | # Enable serving of images, stylesheets, and JavaScripts from an asset server
46 | # config.action_controller.asset_host = "http://assets.example.com"
47 |
48 | # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added)
49 | # config.assets.precompile += %w( search.js )
50 |
51 | # Disable delivery errors, bad email addresses will be ignored
52 | # config.action_mailer.raise_delivery_errors = false
53 |
54 | # Enable threaded mode
55 | # config.threadsafe!
56 |
57 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
58 | # the I18n.default_locale when a translation can not be found)
59 | config.i18n.fallbacks = true
60 |
61 | # Send deprecation notices to registered listeners
62 | config.active_support.deprecation = :notify
63 |
64 | # Log the query plan for queries taking more than this (works
65 | # with SQLite, MySQL, and PostgreSQL)
66 | # config.active_record.auto_explain_threshold_in_seconds = 0.5
67 | end
68 |
--------------------------------------------------------------------------------
/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | ApiExample::Application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb
3 |
4 | # The test environment is used exclusively to run your application's
5 | # test suite. You never need to work with it otherwise. Remember that
6 | # your test database is "scratch space" for the test suite and is wiped
7 | # and recreated between test runs. Don't rely on the data there!
8 | config.cache_classes = true
9 |
10 | # Configure static asset server for tests with Cache-Control for performance
11 | config.serve_static_assets = true
12 | config.static_cache_control = "public, max-age=3600"
13 |
14 | # Log error messages when you accidentally call methods on nil
15 | config.whiny_nils = true
16 |
17 | # Show full error reports and disable caching
18 | config.consider_all_requests_local = true
19 | config.action_controller.perform_caching = false
20 |
21 | # Raise exceptions instead of rendering exception templates
22 | config.action_dispatch.show_exceptions = false
23 |
24 | # Disable request forgery protection in test environment
25 | config.action_controller.allow_forgery_protection = false
26 |
27 | # Tell Action Mailer not to deliver emails to the real world.
28 | # The :test delivery method accumulates sent emails in the
29 | # ActionMailer::Base.deliveries array.
30 | config.action_mailer.delivery_method = :test
31 |
32 | # Raise exception on mass assignment protection for Active Record models
33 | config.active_record.mass_assignment_sanitizer = :strict
34 |
35 | # Print deprecation notices to the stderr
36 | config.active_support.deprecation = :stderr
37 | end
38 |
--------------------------------------------------------------------------------
/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
5 |
6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
7 | # Rails.backtrace_cleaner.remove_silencers!
8 |
--------------------------------------------------------------------------------
/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format
4 | # (all these examples are active by default):
5 | # ActiveSupport::Inflector.inflections do |inflect|
6 | # inflect.plural /^(ox)$/i, '\1en'
7 | # inflect.singular /^(ox)en/i, '\1'
8 | # inflect.irregular 'person', 'people'
9 | # inflect.uncountable %w( fish sheep )
10 | # end
11 | #
12 | # These inflection rules are supported but not enabled by default:
13 | # ActiveSupport::Inflector.inflections do |inflect|
14 | # inflect.acronym 'RESTful'
15 | # end
16 |
--------------------------------------------------------------------------------
/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new mime types for use in respond_to blocks:
4 | # Mime::Type.register "text/richtext", :rtf
5 | # Mime::Type.register_alias "text/html", :iphone
6 |
--------------------------------------------------------------------------------
/config/initializers/secret_token.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Your secret key for verifying the integrity of signed cookies.
4 | # If you change this key, all old signed cookies will become invalid!
5 | # Make sure the secret is at least 30 characters and all random,
6 | # no regular words or you'll be exposed to dictionary attacks.
7 | ApiExample::Application.config.secret_token = '9115184b71c3f2679ffea48826ec329136831c6d93f9b66c465da2f03af53855d481dfe91ff20c0ab1d9c49bfa32f41576c88e1141951ad9d229cb59776a73aa'
8 |
--------------------------------------------------------------------------------
/config/initializers/session_store.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # ApiExample::Application.config.session_store :cookie_store, key: '_api-example_session'
4 |
5 | # Use the database for sessions instead of the cookie-based default,
6 | # which shouldn't be used to store highly confidential information
7 | # (create the session table with "rails generate session_migration")
8 | ApiExample::Application.config.session_store :active_record_store
9 |
--------------------------------------------------------------------------------
/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 | #
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json]
9 | end
10 |
11 | # Disable root element in JSON by default.
12 | ActiveSupport.on_load(:active_record) do
13 | self.include_root_in_json = false
14 | end
15 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Sample localization file for English. Add more files in this directory for other locales.
2 | # See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
3 |
4 | en:
5 | hello: "Hello world"
6 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | ApiExample::Application.routes.draw do
2 | match "user/login", :as => :login
3 | match "user/callback"
4 | match "user/logout"
5 | root :to => 'user#welcome'
6 |
7 | get "user/balance_over_time"
8 | get "user/balances_over_time_with_friends"
9 | get "user/expenses_over_time"
10 | get "user/expenses_by_category"
11 | get "user/expenses_by_category_over_time"
12 | get "user/expenses_matching"
13 | get "user/get_expenses_matching"
14 |
15 |
16 | # The priority is based upon order of creation:
17 | # first created -> highest priority.
18 |
19 | # Sample of regular route:
20 | # match 'products/:id' => 'catalog#view'
21 | # Keep in mind you can assign values other than :controller and :action
22 |
23 | # Sample of named route:
24 | # match 'products/:id/purchase' => 'catalog#purchase', :as => :purchase
25 | # This route can be invoked with purchase_url(:id => product.id)
26 |
27 | # Sample resource route (maps HTTP verbs to controller actions automatically):
28 | # resources :products
29 |
30 | # Sample resource route with options:
31 | # resources :products do
32 | # member do
33 | # get 'short'
34 | # post 'toggle'
35 | # end
36 | #
37 | # collection do
38 | # get 'sold'
39 | # end
40 | # end
41 |
42 | # Sample resource route with sub-resources:
43 | # resources :products do
44 | # resources :comments, :sales
45 | # resource :seller
46 | # end
47 |
48 | # Sample resource route with more complex sub-resources
49 | # resources :products do
50 | # resources :comments
51 | # resources :sales do
52 | # get 'recent', :on => :collection
53 | # end
54 | # end
55 |
56 | # Sample resource route within a namespace:
57 | # namespace :admin do
58 | # # Directs /admin/products/* to Admin::ProductsController
59 | # # (app/controllers/admin/products_controller.rb)
60 | # resources :products
61 | # end
62 |
63 | # You can have the root of your site routed with "root"
64 | # just remember to delete public/index.html.
65 | # root :to => 'welcome#index'
66 |
67 | # See how all your routes lay out with "rake routes"
68 |
69 | # This is a legacy wild controller route that's not recommended for RESTful applications.
70 | # Note: This route will make all actions in every controller accessible via GET requests.
71 | # match ':controller(/:action(/:id))(.:format)'
72 | end
73 |
--------------------------------------------------------------------------------
/db/migrate/20130424233348_create_oauths.rb:
--------------------------------------------------------------------------------
1 | class CreateOauths < ActiveRecord::Migration
2 | def change
3 | create_table :oauths do |t|
4 |
5 | t.timestamps
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/20130424235835_add_sessions_table.rb:
--------------------------------------------------------------------------------
1 | class AddSessionsTable < ActiveRecord::Migration
2 | def change
3 | create_table :sessions do |t|
4 | t.string :session_id, :null => false
5 | t.text :data
6 | t.timestamps
7 | end
8 |
9 | add_index :sessions, :session_id
10 | add_index :sessions, :updated_at
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/db/schema.rb:
--------------------------------------------------------------------------------
1 | # encoding: UTF-8
2 | # This file is auto-generated from the current state of the database. Instead
3 | # of editing this file, please use the migrations feature of Active Record to
4 | # incrementally modify your database, and then regenerate this schema definition.
5 | #
6 | # Note that this schema.rb definition is the authoritative source for your
7 | # database schema. If you need to create the application database on another
8 | # system, you should be using db:schema:load, not running all the migrations
9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
10 | # you'll amass, the slower it'll run and the greater likelihood for issues).
11 | #
12 | # It's strongly recommended to check this file into your version control system.
13 |
14 | ActiveRecord::Schema.define(:version => 20130424235835) do
15 |
16 | create_table "oauths", :force => true do |t|
17 | t.datetime "created_at", :null => false
18 | t.datetime "updated_at", :null => false
19 | end
20 |
21 | create_table "sessions", :force => true do |t|
22 | t.string "session_id", :null => false
23 | t.text "data"
24 | t.datetime "created_at", :null => false
25 | t.datetime "updated_at", :null => false
26 | end
27 |
28 | add_index "sessions", ["session_id"], :name => "index_sessions_on_session_id"
29 | add_index "sessions", ["updated_at"], :name => "index_sessions_on_updated_at"
30 |
31 | end
32 |
--------------------------------------------------------------------------------
/db/seeds.rb:
--------------------------------------------------------------------------------
1 | # This file should contain all the record creation needed to seed the database with its default values.
2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup).
3 | #
4 | # Examples:
5 | #
6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }])
7 | # Mayor.create(name: 'Emanuel', city: cities.first)
8 |
--------------------------------------------------------------------------------
/doc/README_FOR_APP:
--------------------------------------------------------------------------------
1 | Use this README file to introduce your application and point to useful places in the API for learning more.
2 | Run "rake doc:app" to generate API documentation for your models, controllers, helpers, and libraries.
3 |
--------------------------------------------------------------------------------
/lib/assets/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/lib/assets/.gitkeep
--------------------------------------------------------------------------------
/lib/tasks/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/lib/tasks/.gitkeep
--------------------------------------------------------------------------------
/log/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/log/.gitkeep
--------------------------------------------------------------------------------
/public/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/public/.DS_Store
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
18 |
19 |
20 |
21 |
22 |
23 |
The page you were looking for doesn't exist.
24 |
You may have mistyped the address or the page may have moved. Silly you!
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
The change you wanted was rejected.
23 |
Maybe you tried to change something you didn't have access to.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
We're sorry, but something went wrong.
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/public/favicon.ico
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-Agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/script/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application.
3 |
4 | APP_PATH = File.expand_path('../../config/application', __FILE__)
5 | require File.expand_path('../../config/boot', __FILE__)
6 | require 'rails/commands'
7 |
--------------------------------------------------------------------------------
/test/fixtures/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/test/fixtures/.gitkeep
--------------------------------------------------------------------------------
/test/fixtures/oauths.yml:
--------------------------------------------------------------------------------
1 | # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html
2 |
3 | # This model initially had no columns defined. If you add columns to the
4 | # model remove the '{}' from the fixture names and add the columns immediately
5 | # below each fixture, per the syntax in the comments below
6 | #
7 | one: {}
8 | # column: value
9 | #
10 | two: {}
11 | # column: value
12 |
--------------------------------------------------------------------------------
/test/functional/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/test/functional/.gitkeep
--------------------------------------------------------------------------------
/test/functional/oauth_controller_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class OauthControllerTest < ActionController::TestCase
4 | test "should get login" do
5 | get :login
6 | assert_response :success
7 | end
8 |
9 | end
10 |
--------------------------------------------------------------------------------
/test/integration/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/test/integration/.gitkeep
--------------------------------------------------------------------------------
/test/performance/browsing_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 | require 'rails/performance_test_help'
3 |
4 | class BrowsingTest < ActionDispatch::PerformanceTest
5 | # Refer to the documentation for all available options
6 | # self.profile_options = { :runs => 5, :metrics => [:wall_time, :memory]
7 | # :output => 'tmp/performance', :formats => [:flat] }
8 |
9 | def test_homepage
10 | get '/'
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | ENV["RAILS_ENV"] = "test"
2 | require File.expand_path('../../config/environment', __FILE__)
3 | require 'rails/test_help'
4 |
5 | class ActiveSupport::TestCase
6 | # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order.
7 | #
8 | # Note: You'll currently still have to declare fixtures explicitly in integration tests
9 | # -- they do not yet inherit this setting
10 | fixtures :all
11 |
12 | # Add more helper methods to be used by all tests here...
13 | end
14 |
--------------------------------------------------------------------------------
/test/unit/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/test/unit/.gitkeep
--------------------------------------------------------------------------------
/test/unit/helpers/oauth_helper_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class OauthHelperTest < ActionView::TestCase
4 | end
5 |
--------------------------------------------------------------------------------
/test/unit/oauth_test.rb:
--------------------------------------------------------------------------------
1 | require 'test_helper'
2 |
3 | class OauthTest < ActiveSupport::TestCase
4 | # test "the truth" do
5 | # assert true
6 | # end
7 | end
8 |
--------------------------------------------------------------------------------
/vendor/assets/javascripts/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/vendor/assets/javascripts/.gitkeep
--------------------------------------------------------------------------------
/vendor/assets/stylesheets/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/vendor/assets/stylesheets/.gitkeep
--------------------------------------------------------------------------------
/vendor/plugins/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/splitwise/api-example/74b531eb178c1b7295d964001e61dca4faa1f90e/vendor/plugins/.gitkeep
--------------------------------------------------------------------------------