├── .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 | [![Kittens](http://dx0qysuen8cbs.cloudfront.net/assets/kittens/kittens-c3c5045b19e60818353808bfd050cf02.jpg "Yay kittens!")](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 |
    5 |
    6 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    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 |
    5 |
    6 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    -------------------------------------------------------------------------------- /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 |
    5 |
    6 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    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 |
    5 |
    6 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    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 |
    28 |
    29 |
    30 |
    31 |
    32 |
    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 |
    5 |
    6 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    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 --------------------------------------------------------------------------------