├── .gitignore ├── LICENSE ├── README.md ├── brunch-config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── phoenix_toggl.ex └── phoenix_toggl │ ├── endpoint.ex │ ├── guardian_serializer.ex │ ├── repo.ex │ ├── reports │ ├── data.ex │ ├── day.ex │ └── reporter.ex │ ├── timer_monitor.ex │ ├── timer_monitor │ └── supervisor.ex │ ├── workspace_monitor.ex │ └── workspace_monitor │ └── supervisor.ex ├── mix.exs ├── mix.lock ├── package.json ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── 20160129095239_create_user.exs │ ├── 20160201142815_create_workspace.exs │ ├── 20160203095715_create_workspace_user.exs │ └── 20160203095815_create_time_entry.exs │ └── seeds.exs ├── test ├── actions │ └── time_entry_actions_test.exs ├── channels │ ├── user_channel_test.exs │ └── workspace_channel_test.exs ├── controllers │ └── page_controller_test.exs ├── integration │ ├── sign_in_test.exs │ └── sign_up_test.exs ├── lib │ ├── reporter_test.exs │ ├── timer_monitor_test.exs │ └── workspace_monitor_test.exs ├── models │ ├── time_entry_test.exs │ ├── user_test.exs │ └── workspace_test.exs ├── support │ ├── channel_case.ex │ ├── conn_case.ex │ ├── factory.ex │ ├── integration_case.ex │ └── model_case.ex ├── test_helper.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs └── web ├── actions └── time_entry_actions.ex ├── channels ├── user_channel.ex ├── user_socket.ex └── workspace_channel.ex ├── controllers ├── api │ └── v1 │ │ ├── current_user_controller.ex │ │ ├── registration_controller.ex │ │ ├── session_controller.ex │ │ └── time_entry_controller.ex └── page_controller.ex ├── gettext.ex ├── helpers └── session.ex ├── models ├── time_entry.ex ├── user.ex ├── workspace.ex └── workspace_user.ex ├── router.ex ├── static ├── assets │ ├── favicon.ico │ └── robots.txt ├── css-burrito-config.json ├── css │ ├── app.sass │ ├── bitters │ │ ├── _base.sass │ │ ├── _buttons.sass │ │ ├── _forms.sass │ │ ├── _grid-settings.sass │ │ ├── _lists.sass │ │ ├── _tables.sass │ │ ├── _typography.sass │ │ └── _variables.sass │ ├── global │ │ ├── _base.sass │ │ ├── _layout.sass │ │ ├── _settings.sass │ │ ├── _skin.sass │ │ ├── _typography.sass │ │ └── _utilities.sass │ ├── libs │ │ ├── _library-variable-overrides.sass │ │ └── _normalize.sass │ └── modules │ │ ├── _example-module.sass │ │ ├── _main_footer.sass │ │ ├── _main_header.sass │ │ ├── _modules.sass │ │ ├── auth │ │ └── _new.sass │ │ ├── home │ │ └── _index.sass │ │ └── reports │ │ └── _index.sass └── js │ ├── actions │ ├── header.js │ ├── registrations.js │ ├── reports.js │ ├── sessions.js │ ├── time_entries.js │ └── timer.js │ ├── app.js │ ├── components │ ├── reports │ │ ├── bar.js │ │ ├── container.js │ │ ├── grid.js │ │ └── range_selector.js │ ├── time_entries │ │ └── item.js │ └── timer │ │ └── index.js │ ├── constants │ └── index.js │ ├── containers │ ├── authenticated.js │ └── root.js │ ├── layouts │ ├── header.js │ └── main.js │ ├── reducers │ ├── header.js │ ├── index.js │ ├── registration.js │ ├── reports.js │ ├── session.js │ ├── time_entries.js │ └── timer.js │ ├── routes │ └── index.js │ ├── store │ └── index.js │ ├── utils │ └── index.js │ └── views │ ├── home │ └── index.js │ ├── registrations │ └── new.js │ ├── reports │ └── index.js │ └── sessions │ └── new.js ├── templates ├── layout │ └── app.html.eex └── page │ └── index.html.eex ├── views ├── current_user_view.ex ├── error_helpers.ex ├── error_view.ex ├── layout_view.ex ├── page_view.ex ├── registration_view.ex └── session_view.ex └── web.ex /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generate on crash by the VM 8 | erl_crash.dump 9 | 10 | # Static artifacts 11 | /node_modules 12 | 13 | # Since we are building assets from web/static, 14 | # we ignore priv/static. You may want to comment 15 | # this depending on your deployment strategy. 16 | /priv/static/ 17 | 18 | # The config/prod.secret.exs file by default contains sensitive 19 | # data and you should not commit it into version control. 20 | # 21 | # Alternatively, you may comment the line below and commit the 22 | # secrets file as long as you replace its contents by environment 23 | # variables. 24 | /config/prod.secret.exs 25 | /priv/static/css/app.css 26 | /priv/static/js/app.js 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ricardo García Vega 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix Toggl 2 | [Toggl](https://toggl.com/) tribute done with [Elixir](https://github.com/elixir-lang/elixir), [Phoenix Framework](https://github.com/phoenixframework/phoenix), [React](https://github.com/facebook/react) and [Redux](https://github.com/reactjs/redux). 3 | 4 | ![`timer`](http://codeloveandboards.com/images/blog/toggl_tribute/timer-e9b1582f.jpg) 5 | 6 | ## Live demo 7 | https://phoenix-toggl.herokuapp.com 8 | 9 | ## Requirements 10 | You need to have **Elixir v1.2** and **PostgreSQL** installed. 11 | 12 | ## Installation instructions 13 | To start your Phoenix Trello app: 14 | 15 | 1. Install dependencies with `mix deps.get` 16 | 2. Install npm packages with `npm install` 17 | 3. Create and migrate your database with `mix ecto.create && mix ecto.migrate` 18 | 4. Run seeds to create demo user with `mix run priv/repo/seeds.exs` 19 | 5. Start Phoenix endpoint with `mix phoenix.server` 20 | 21 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 22 | 23 | Enjoy! 24 | 25 | ## Testing 26 | Integration tests with [Hound](https://github.com/HashNuke/hound) and [Selenium ChromeDriver](https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver). Instructions: 27 | 28 | 1. Install **ChromeDriver** with `npm install -g chromedriver` 29 | 2. Run **ChromeDriver** in a new terminal window with `chromedriver` 30 | 3. Run tests with `mix test` 31 | 32 | If you don't want to run integration tests just run `mix test --exclude integration`. 33 | 34 | ## License 35 | 36 | See [LICENSE](LICENSE). 37 | -------------------------------------------------------------------------------- /brunch-config.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | // See http://brunch.io/#documentation for docs. 3 | files: { 4 | javascripts: { 5 | joinTo: 'js/app.js', 6 | 7 | // To use a separate vendor.js bundle, specify two files path 8 | // https://github.com/brunch/brunch/blob/stable/docs/config.md#files 9 | // joinTo: { 10 | // "js/app.js": /^(web\/static\/js)/, 11 | // "js/vendor.js": /^(web\/static\/vendor)|(deps)/ 12 | // } 13 | // 14 | // To change the order of concatenation of files, explicitly mention here 15 | // https://github.com/brunch/brunch/tree/master/docs#concatenation 16 | // order: { 17 | // before: [ 18 | // "web/static/vendor/js/jquery-2.1.1.js", 19 | // "web/static/vendor/js/bootstrap.min.js" 20 | // ] 21 | // } 22 | }, 23 | stylesheets: { 24 | joinTo: 'css/app.css', 25 | }, 26 | templates: { 27 | joinTo: 'js/app.js', 28 | }, 29 | }, 30 | 31 | conventions: { 32 | // This option sets where we should place non-css and non-js assets in. 33 | // By default, we set this to "/web/static/assets". Files in this directory 34 | // will be copied to `paths.public`, which is "priv/static" by default. 35 | assets: /^(web\/static\/assets)/, 36 | }, 37 | 38 | // Phoenix paths configuration 39 | paths: { 40 | // Dependencies and current project directories to watch 41 | watched: [ 42 | 'web/static', 43 | 'test/static', 44 | ], 45 | 46 | // Where to compile files to 47 | public: 'priv/static', 48 | }, 49 | 50 | // Configure your plugins 51 | plugins: { 52 | babel: { 53 | presets: ['es2015', 'react', 'stage-2', 'stage-0'], 54 | 55 | // Do not use ES6 compiler in vendor code 56 | ignore: [/web\/static\/vendor/], 57 | }, 58 | sass: { 59 | options: { 60 | includePaths: ['node_modules'], 61 | }, 62 | }, 63 | }, 64 | 65 | modules: { 66 | autoRequire: { 67 | 'js/app.js': ['web/static/js/app'], 68 | }, 69 | }, 70 | 71 | npm: { 72 | enabled: true, 73 | 74 | // Whitelist the npm deps to be pulled in as front-end assets. 75 | // All other deps in package.json will be excluded from the bundle. 76 | whitelist: [ 77 | 'bourbon', 78 | 'bourbon-neat', 79 | 'classnames', 80 | 'es6-promise', 81 | 'history', 82 | 'invariant', 83 | 'isomorphic-fetch', 84 | 'moment', 85 | 'phoenix', 86 | 'phoenix_html', 87 | 'react', 88 | 'react-addons-css-transition-group', 89 | 'react-dom', 90 | 'react-gravatar', 91 | 'react-page-click', 92 | 'react-redux', 93 | 'react-router', 94 | 'react-router-redux', 95 | 'redux', 96 | 'redux-logger', 97 | 'redux-thunk', 98 | 'react-favicon', 99 | ], 100 | }, 101 | }; 102 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # Configures the endpoint 9 | config :phoenix_toggl, PhoenixToggl.Endpoint, 10 | url: [host: "localhost"], 11 | root: Path.dirname(__DIR__), 12 | secret_key_base: "fwhgWA/EEgRzxIZuGabA7q+HWvDqgQvQqTHGK99X4H6nL0vUl19Hjv2/+VgWGUhQ", 13 | render_errors: [accepts: ~w(html json)], 14 | pubsub: [name: PhoenixToggl.PubSub, 15 | adapter: Phoenix.PubSub.PG2] 16 | 17 | # Configures Elixir's Logger 18 | config :logger, :console, 19 | format: "$time $metadata[$level] $message\n", 20 | metadata: [:request_id] 21 | 22 | # Import environment specific config. This must remain at the bottom 23 | # of this file so it overrides the configuration defined above. 24 | import_config "#{Mix.env}.exs" 25 | 26 | # Configure phoenix generators 27 | config :phoenix, :generators, 28 | migration: true, 29 | binary_id: false 30 | 31 | # Configure guardian 32 | config :guardian, Guardian, 33 | issuer: "PhoenixToggl", 34 | ttl: { 3, :days }, 35 | verify_issuer: true, 36 | serializer: PhoenixToggl.GuardianSerializer 37 | 38 | # Start Hound for ChromeDriver 39 | config :hound, driver: "chrome_driver" 40 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :phoenix_toggl, PhoenixToggl.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin"]] 15 | 16 | # Watch static and templates for browser reloading. 17 | config :phoenix_toggl, PhoenixToggl.Endpoint, 18 | live_reload: [ 19 | patterns: [ 20 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 21 | ~r{priv/gettext/.*(po)$}, 22 | ~r{web/views/.*(ex)$}, 23 | ~r{web/templates/.*(eex)$} 24 | ] 25 | ] 26 | 27 | # Do not include metadata nor timestamps in development logs 28 | config :logger, :console, format: "[$level] $message\n" 29 | 30 | # Set a higher stacktrace during development. 31 | # Do not configure such in production as keeping 32 | # and calculating stacktraces is usually expensive. 33 | config :phoenix, :stacktrace_depth, 20 34 | 35 | # Configure your database 36 | config :phoenix_toggl, PhoenixToggl.Repo, 37 | adapter: Ecto.Adapters.Postgres, 38 | username: "postgres", 39 | password: "postgres", 40 | database: "phoenix_toggl_dev", 41 | hostname: "localhost", 42 | pool_size: 10 43 | 44 | # Guardian configuration 45 | config :guardian, Guardian, 46 | secret_key: "Y8+P3Plvr/7bDo38ySz5s8K1hRpzERiDmmjw4v7W+7EQ2XAFG/qdZhE0xFE8Be8D" 47 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we configure the host to read the PORT 4 | # from the system environment. Therefore, you will need 5 | # to set PORT=80 before running your server. 6 | # 7 | # You should also configure the url host to something 8 | # meaningful, we use this information when generating URLs. 9 | # 10 | # Finally, we also include the path to a manifest 11 | # containing the digested version of static files. This 12 | # manifest is generated by the mix phoenix.digest task 13 | # which you typically run after static files are built. 14 | config :phoenix_toggl, PhoenixToggl.Endpoint, 15 | http: [port: {:system, "PORT"}], 16 | url: [host: "example.com", port: 80], 17 | cache_static_manifest: "priv/static/manifest.json" 18 | 19 | # Do not print debug messages in production 20 | config :logger, level: :info 21 | 22 | # ## SSL Support 23 | # 24 | # To get SSL working, you will need to add the `https` key 25 | # to the previous section and set your `:url` port to 443: 26 | # 27 | # config :phoenix_toggl, PhoenixToggl.Endpoint, 28 | # ... 29 | # url: [host: "example.com", port: 443], 30 | # https: [port: 443, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 33 | # 34 | # Where those two env variables return an absolute path to 35 | # the key and cert in disk or a relative path inside priv, 36 | # for example "priv/ssl/server.key". 37 | # 38 | # We also recommend setting `force_ssl`, ensuring no data is 39 | # ever sent via http, always redirecting to https: 40 | # 41 | # config :phoenix_toggl, PhoenixToggl.Endpoint, 42 | # force_ssl: [hsts: true] 43 | # 44 | # Check `Plug.SSL` for all available options in `force_ssl`. 45 | 46 | # ## Using releases 47 | # 48 | # If you are doing OTP releases, you need to instruct Phoenix 49 | # to start the server for all endpoints: 50 | # 51 | # config :phoenix, :serve_endpoints, true 52 | # 53 | # Alternatively, you can configure exactly which server to 54 | # start per endpoint: 55 | # 56 | # config :phoenix_toggl, PhoenixToggl.Endpoint, server: true 57 | # 58 | # You will also need to set the application root to `.` in order 59 | # for the new static assets to be served after a hot upgrade: 60 | # 61 | # config :phoenix_toggl, PhoenixToggl.Endpoint, root: "." 62 | 63 | # Finally import the config/prod.secret.exs 64 | # which should be versioned separately. 65 | import_config "prod.secret.exs" 66 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :phoenix_toggl, PhoenixToggl.Endpoint, 6 | http: [port: 4001], 7 | server: true 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :phoenix_toggl, PhoenixToggl.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: "postgres", 16 | password: "postgres", 17 | database: "phoenix_toggl_test", 18 | hostname: "localhost", 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | 21 | # Guardian configuration 22 | config :guardian, Guardian, 23 | secret_key: "Y8+P3Plvr/7bDo38ySz5s8K1hRpzERiDmmjw4v7W+7EQ2XAFG/qdZhE0xFE8Be8D" 24 | -------------------------------------------------------------------------------- /lib/phoenix_toggl.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [ 10 | # Start the endpoint when the application starts 11 | supervisor(PhoenixToggl.Endpoint, []), 12 | # Start the Ecto repository 13 | supervisor(PhoenixToggl.Repo, []), 14 | # Here you could define other workers and supervisors as children 15 | # worker(PhoenixToggl.Worker, [arg1, arg2, arg3]), 16 | supervisor(PhoenixToggl.WorkspaceMonitor.Supervisor, []), 17 | supervisor(PhoenixToggl.TimerMonitor.Supervisor, []), 18 | ] 19 | 20 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 21 | # for other strategies and supported options 22 | opts = [strategy: :one_for_one, name: PhoenixToggl.Supervisor] 23 | Supervisor.start_link(children, opts) 24 | end 25 | 26 | # Tell Phoenix to update the endpoint configuration 27 | # whenever the application is updated. 28 | def config_change(changed, _new, removed) do 29 | PhoenixToggl.Endpoint.config_change(changed, removed) 30 | :ok 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :phoenix_toggl 3 | 4 | socket "/socket", PhoenixToggl.UserSocket 5 | 6 | # Serve at "/" the static files from "priv/static" directory. 7 | # 8 | # You should set gzip to true if you are running phoenix.digest 9 | # when deploying your static files in production. 10 | plug Plug.Static, 11 | at: "/", from: :phoenix_toggl, gzip: false, 12 | only: ~w(css fonts images js favicon.ico robots.txt) 13 | 14 | # Code reloading can be explicitly enabled under the 15 | # :code_reloader configuration of your endpoint. 16 | if code_reloading? do 17 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 18 | plug Phoenix.LiveReloader 19 | plug Phoenix.CodeReloader 20 | end 21 | 22 | plug Plug.RequestId 23 | plug Plug.Logger 24 | 25 | plug Plug.Parsers, 26 | parsers: [:urlencoded, :multipart, :json], 27 | pass: ["*/*"], 28 | json_decoder: Poison 29 | 30 | plug Plug.MethodOverride 31 | plug Plug.Head 32 | 33 | plug Plug.Session, 34 | store: :cookie, 35 | key: "_phoenix_toggl_key", 36 | signing_salt: "0NfPCK+m" 37 | 38 | plug PhoenixToggl.Router 39 | end 40 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/guardian_serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.GuardianSerializer do 2 | @behaviour Guardian.Serializer 3 | 4 | alias PhoenixToggl.{Repo, User} 5 | 6 | def for_token(user = %User{}), do: { :ok, "User:#{user.id}" } 7 | def for_token(_), do: { :error, "Unknown resource type" } 8 | 9 | def from_token("User:" <> id), do: { :ok, Repo.get(User, String.to_integer(id)) } 10 | def from_token(_), do: { :error, "Unknown resource type" } 11 | end 12 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Repo do 2 | use Ecto.Repo, otp_app: :phoenix_toggl 3 | end 4 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/reports/data.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Reports.Data do 2 | @moduledoc """ 3 | Basic structure for report data 4 | 5 | - user_id: Owner id 6 | - start_date: Initial time from where to fetch time entries. 7 | - end_date: Finish time 8 | - total_duration: Sum of all days durations 9 | - days: List of durations per day between start_date and end_date 10 | """ 11 | defstruct [ 12 | user_id: nil, 13 | start_date: 0, 14 | end_date: 0, 15 | total_duration: 0, 16 | days: [] 17 | ] 18 | end 19 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/reports/day.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Reports.Day do 2 | @moduledoc """ 3 | Basic structure for a data day 4 | 5 | - date: Date inside the data range 6 | - duration: Sum of all time entries durations created that day 7 | """ 8 | defstruct [ 9 | id: 0, 10 | date: nil, 11 | duration: 0 12 | ] 13 | end 14 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/reports/reporter.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Reports.Reporter do 2 | @moduledoc """ 3 | Retrieves necessary data from the database 4 | """ 5 | alias PhoenixToggl.{Repo, TimeEntry} 6 | alias PhoenixToggl.Reports.{Data, Day} 7 | alias Timex.{Date, Time, DateFormat} 8 | 9 | @format_string "%Y-%m-%d" 10 | 11 | def generate(), do: {:error, :invalid_params} 12 | def generate(%{user: _user, number_of_weeks: _number_of_weeks} = params) do 13 | generate_data params 14 | end 15 | def generate(_), do: {:error, :invalid_params} 16 | 17 | defp generate_data(%{user: user, number_of_weeks: number_of_weeks}) do 18 | now = Date.now 19 | start_date = now 20 | |> Date.subtract(Time.to_timestamp(number_of_weeks - 1, :weeks)) 21 | |> Date.beginning_of_week(:mon) 22 | end_date = Date.end_of_week(now, :mon) 23 | 24 | days = Date.diff(start_date, end_date, :days) 25 | days_data = process_days(user, days, start_date) 26 | total_duration = calculate_total_duration(days_data) 27 | 28 | %Data{ 29 | user_id: user.id, 30 | start_date: format_date(start_date), 31 | end_date: format_date(end_date), 32 | total_duration: total_duration, 33 | days: days_data 34 | } 35 | end 36 | 37 | defp process_days(user, days, start_date) do 38 | 0..days 39 | |> Enum.map(fn(day) -> Task.async(fn -> calculate_day(user, start_date, day) end) end) 40 | |> Enum.map(&(Task.await(&1))) 41 | |> Enum.sort(&(&1.id < &2.id)) 42 | end 43 | 44 | defp calculate_day(user, start_date, day_number) do 45 | date = start_date 46 | |> Date.add(Time.to_timestamp(day_number, :days)) 47 | |> format_date 48 | 49 | total_duration = user 50 | |> Ecto.assoc(:time_entries) 51 | |> TimeEntry.total_duration_for_date(date) 52 | |> Repo.one! 53 | |> case do 54 | nil -> 0 55 | duration -> duration 56 | end 57 | 58 | %Day{ 59 | id: day_number, 60 | date: date, 61 | duration: total_duration 62 | } 63 | end 64 | 65 | defp calculate_total_duration(days_data), do: Enum.reduce(days_data, 0, fn(x, acc) -> x.duration + acc end) 66 | 67 | defp format_date(date), do: DateFormat.format!(date, @format_string, :strftime) 68 | end 69 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/timer_monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimerMonitor do 2 | @moduledoc """ 3 | Stores the current active TimeEntry timer for a given user in a map 4 | containing the following information: 5 | 6 | - time_entry_id 7 | - started_at 8 | 9 | """ 10 | 11 | use GenServer 12 | 13 | def create(user_id) do 14 | case GenServer.whereis(ref(user_id)) do 15 | nil -> 16 | Supervisor.start_child(__MODULE__.Supervisor, [user_id]) 17 | _timer -> 18 | {:error, :timer_already_exists} 19 | end 20 | end 21 | 22 | def start_link(user_id) do 23 | GenServer.start_link(__MODULE__, %{time_entry_id: nil, start: nil}, name: ref(user_id)) 24 | end 25 | 26 | def start(user_id, time_entry_id, started_at) do 27 | try_call(user_id, {:start, time_entry_id, started_at}) 28 | end 29 | 30 | def stop(user_id) do 31 | try_call(user_id, :stop) 32 | end 33 | 34 | def info(user_id) do 35 | try_call(user_id, :info) 36 | end 37 | 38 | def handle_call({:start, _id, _started_at}, _from, %{time_entry_id: time_entry_id} = state) when time_entry_id != nil, do: {:reply, :timer_already_started, state} 39 | def handle_call({:start, id, started_at}, _from, _state) do 40 | new_state = %{ 41 | time_entry_id: id, 42 | started_at: started_at 43 | } 44 | 45 | {:reply, new_state, new_state} 46 | end 47 | 48 | def handle_call(:stop, _from, state) do 49 | {:stop, :normal, :ok, state} 50 | end 51 | 52 | def handle_call(:info, _from, state) do 53 | {:reply, state, state} 54 | end 55 | 56 | defp try_call(user_id, call_function) do 57 | case GenServer.whereis(ref(user_id)) do 58 | nil -> 59 | {:error, :invalid_timer} 60 | timer -> 61 | GenServer.call(timer, call_function) 62 | end 63 | end 64 | 65 | defp ref(user_id) do 66 | {:global, {:timer, user_id}} 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/timer_monitor/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimerMonitor.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 6 | end 7 | 8 | def init(:ok) do 9 | children = [ 10 | worker(PhoenixToggl.TimerMonitor, [], restart: :transient) 11 | ] 12 | 13 | supervise(children, strategy: :simple_one_for_one) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/workspace_monitor.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.WorkspaceMonitor do 2 | @moduledoc """ 3 | Stores a list of the connected users ids in a 4 | given workspace. 5 | """ 6 | use GenServer 7 | 8 | # Client interface 9 | 10 | @doc """ 11 | This will force the PhoenixToggl.WorkspaceMonitor.Supervisor to start 12 | a new child if it not already exists 13 | """ 14 | def create(workspace_id) do 15 | case GenServer.whereis(ref(workspace_id)) do 16 | nil -> 17 | Supervisor.start_child(__MODULE__.Supervisor, [workspace_id]) 18 | _workspace -> 19 | {:error, :workspace_already_exists} 20 | end 21 | end 22 | 23 | def start_link(workspace_id) do 24 | GenServer.start_link(__MODULE__, [], name: ref(workspace_id)) 25 | end 26 | 27 | @doc """ 28 | Adds a user to the workspace. 29 | """ 30 | def join(workspace_id, user_id) do 31 | try_call workspace_id, {:join, user_id} 32 | end 33 | 34 | @doc """ 35 | Returns back all connected users in a workspace 36 | """ 37 | def members(workspace_id) do 38 | try_call workspace_id, :members 39 | end 40 | 41 | @doc """ 42 | Removes a user from a workspace 43 | """ 44 | def leave(workspace_id, user_id) do 45 | try_call workspace_id, {:leave, user_id} 46 | end 47 | 48 | # Server interface 49 | 50 | def handle_call({:join, user_id}, _from, users) do 51 | users = [user_id] ++ users 52 | 53 | {:reply, :ok, users} 54 | end 55 | 56 | def handle_call(:members, _from, users) do 57 | {:reply, users, users} 58 | end 59 | 60 | def handle_call({:leave, user_id}, _from, users) do 61 | users = List.delete(users, user_id) 62 | 63 | case length(users) do 64 | 0 -> {:stop, :normal, :ok, users} 65 | _ -> {:reply, users, users} 66 | end 67 | end 68 | 69 | defp ref(workspace_id) do 70 | {:global, {:workspace, workspace_id}} 71 | end 72 | 73 | defp try_call(workspace_id, call_function) do 74 | case GenServer.whereis(ref(workspace_id)) do 75 | nil -> 76 | {:error, :invalid_workspace} 77 | workspace -> 78 | GenServer.call(workspace, call_function) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/phoenix_toggl/workspace_monitor/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.WorkspaceMonitor.Supervisor do 2 | use Supervisor 3 | 4 | def start_link do 5 | Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) 6 | end 7 | 8 | def init(:ok) do 9 | children = [ 10 | worker(PhoenixToggl.WorkspaceMonitor, [], restart: :temporary) 11 | ] 12 | 13 | supervise(children, strategy: :simple_one_for_one) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :phoenix_toggl, 6 | version: "0.0.2", 7 | elixir: "~> 1.0", 8 | elixirc_paths: elixirc_paths(Mix.env), 9 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases, 13 | deps: deps] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [mod: {PhoenixToggl, []}, 21 | applications: [ 22 | :phoenix, 23 | :phoenix_html, 24 | :cowboy, 25 | :logger, 26 | :gettext, 27 | :phoenix_ecto, 28 | :postgrex, 29 | :comeonin, 30 | :tzdata 31 | ] 32 | ] 33 | end 34 | 35 | # Specifies which paths to compile per environment. 36 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 37 | defp elixirc_paths(_), do: ["lib", "web"] 38 | 39 | # Specifies your project dependencies. 40 | # 41 | # Type `mix help deps` for examples and options. 42 | defp deps do 43 | [ 44 | {:phoenix, "~> 1.1.4"}, 45 | {:postgrex, ">= 0.0.0"}, 46 | {:phoenix_ecto, "~> 2.0"}, 47 | {:phoenix_html, "~> 2.4"}, 48 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 49 | {:gettext, "~> 0.9"}, 50 | {:cowboy, "~> 1.0"}, 51 | {:comeonin, "~> 2.0"}, 52 | {:guardian, "~> 0.9.0"}, 53 | {:credo, "~> 0.2", only: [:dev, :test]}, 54 | {:timex, "~> 1.0"}, 55 | {:timex_ecto, "~> 0.9.0"}, 56 | {:mix_test_watch, "~> 0.2", only: :dev}, 57 | {:ex_machina, "~> 0.6.1", only: :test}, 58 | {:hound, "~> 0.8"} 59 | ] 60 | end 61 | 62 | # Aliases are shortcut or tasks specific to the current project. 63 | # For example, to create, migrate and run the seeds file at once: 64 | # 65 | # $ mix ecto.setup 66 | # 67 | # See the documentation for `Mix` for more info on aliases. 68 | defp aliases do 69 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 70 | "ecto.reset": ["ecto.drop", "ecto.setup"]] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"base64url": {:hex, :base64url, "0.0.1"}, 2 | "bunt": {:hex, :bunt, "0.1.5"}, 3 | "certifi": {:hex, :certifi, "0.3.0"}, 4 | "combine": {:hex, :combine, "0.7.0"}, 5 | "comeonin": {:hex, :comeonin, "2.1.1"}, 6 | "connection": {:hex, :connection, "1.0.2"}, 7 | "cowboy": {:hex, :cowboy, "1.0.4"}, 8 | "cowlib": {:hex, :cowlib, "1.0.2"}, 9 | "credo": {:hex, :credo, "0.3.3"}, 10 | "db_connection": {:hex, :db_connection, "0.2.3"}, 11 | "decimal": {:hex, :decimal, "1.1.1"}, 12 | "ecto": {:hex, :ecto, "1.1.3"}, 13 | "ex_machina": {:hex, :ex_machina, "0.6.1"}, 14 | "fs": {:hex, :fs, "0.9.2"}, 15 | "gettext": {:hex, :gettext, "0.9.0"}, 16 | "guardian": {:hex, :guardian, "0.9.1"}, 17 | "hackney": {:hex, :hackney, "1.4.8"}, 18 | "hound": {:hex, :hound, "0.8.2"}, 19 | "httpoison": {:hex, :httpoison, "0.8.1"}, 20 | "idna": {:hex, :idna, "1.0.3"}, 21 | "jose": {:hex, :jose, "1.6.1"}, 22 | "mimerl": {:hex, :mimerl, "1.0.2"}, 23 | "mix_test_watch": {:hex, :mix_test_watch, "0.2.5"}, 24 | "phoenix": {:hex, :phoenix, "1.1.4"}, 25 | "phoenix_ecto": {:hex, :phoenix_ecto, "2.0.1"}, 26 | "phoenix_html": {:hex, :phoenix_html, "2.5.0"}, 27 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.3"}, 28 | "plug": {:hex, :plug, "1.1.1"}, 29 | "poison": {:hex, :poison, "1.5.2"}, 30 | "poolboy": {:hex, :poolboy, "1.5.1"}, 31 | "postgrex": {:hex, :postgrex, "0.11.1"}, 32 | "ranch": {:hex, :ranch, "1.2.1"}, 33 | "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}, 34 | "timex": {:hex, :timex, "1.0.1"}, 35 | "timex_ecto": {:hex, :timex_ecto, "0.9.0"}, 36 | "tzdata": {:hex, :tzdata, "0.5.6"}, 37 | "uuid": {:hex, :uuid, "1.1.3"}} 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "dependencies": { 4 | "babel-brunch": "^6.0.2", 5 | "babel-preset-es2015": "^6.6.0", 6 | "babel-preset-react": "^6.5.0", 7 | "babel-preset-stage-0": "^6.5.0", 8 | "babel-preset-stage-2": "^6.5.0", 9 | "bourbon": "^4.2.6", 10 | "bourbon-neat": "^1.7.4", 11 | "brunch": "github:brunch/brunch", 12 | "classnames": "^2.2.3", 13 | "clean-css-brunch": "^2.0.0", 14 | "css-brunch": "^2.0.0", 15 | "es6-promise": "^3.1.2", 16 | "history": "^2.0.1", 17 | "invariant": "^2.2.1", 18 | "isomorphic-fetch": "^2.2.1", 19 | "javascript-brunch": "^2.0.0", 20 | "moment": "^2.12.0", 21 | "phoenix": "file:deps/phoenix", 22 | "phoenix_html": "file:deps/phoenix_html", 23 | "react": "^0.14.7", 24 | "react-addons-css-transition-group": "^0.14.7", 25 | "react-dom": "^0.14.7", 26 | "react-favicon": "0.0.3", 27 | "react-gravatar": "^2.4.0", 28 | "react-page-click": "^2.0.0", 29 | "react-redux": "^4.4.1", 30 | "react-router": "^2.0.1", 31 | "react-router-redux": "^4.0.0", 32 | "redux": "^3.3.1", 33 | "redux-logger": "^2.6.1", 34 | "redux-thunk": "^2.0.1", 35 | "sass-brunch": "^2.0.0", 36 | "tocktimer": "^1.0.9", 37 | "uglify-js-brunch": "^2.0.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. Do not add, change, or 2 | ## remove `msgid`s manually here as they're tied to the ones in the 3 | ## corresponding POT file (with the same domain). Use `mix gettext.extract 4 | ## --merge` or `mix gettext.merge` to merge POT files into PO files. 5 | msgid "" 6 | msgstr "" 7 | "Language: en\n" 8 | 9 | ## From Ecto.Changeset.cast/4 10 | msgid "can't be blank" 11 | msgstr "" 12 | 13 | ## From Ecto.Changeset.unique_constraint/3 14 | msgid "has already been taken" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.put_change/3 18 | msgid "is invalid" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.validate_format/3 22 | msgid "has invalid format" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_subset/3 26 | msgid "has an invalid entry" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_exclusion/3 30 | msgid "is reserved" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_confirmation/3 34 | msgid "does not match confirmation" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.no_assoc_constraint/3 38 | msgid "is still associated to this entry" 39 | msgstr "" 40 | 41 | msgid "are still associated to this entry" 42 | msgstr "" 43 | 44 | ## From Ecto.Changeset.validate_length/3 45 | msgid "should be %{count} character(s)" 46 | msgid_plural "should be %{count} character(s)" 47 | msgstr[0] "" 48 | msgstr[1] "" 49 | 50 | msgid "should have %{count} item(s)" 51 | msgid_plural "should have %{count} item(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should be at least %{count} character(s)" 56 | msgid_plural "should be at least %{count} character(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should have at least %{count} item(s)" 61 | msgid_plural "should have at least %{count} item(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should be at most %{count} character(s)" 66 | msgid_plural "should be at most %{count} character(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should have at most %{count} item(s)" 71 | msgid_plural "should have at most %{count} item(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | ## From Ecto.Changeset.validate_number/3 76 | msgid "must be less than %{count}" 77 | msgid_plural "must be less than %{count}" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | msgid "must be greater than %{count}" 82 | msgid_plural "must be greater than %{count}" 83 | msgstr[0] "" 84 | msgstr[1] "" 85 | 86 | msgid "must be less than or equal to %{count}" 87 | msgid_plural "must be less than or equal to %{count}" 88 | msgstr[0] "" 89 | msgstr[1] "" 90 | 91 | msgid "must be greater than or equal to %{count}" 92 | msgid_plural "must be greater than or equal to %{count}" 93 | msgstr[0] "" 94 | msgstr[1] "" 95 | 96 | msgid "must be equal to %{count}" 97 | msgid_plural "must be equal to %{count}" 98 | msgstr[0] "" 99 | msgstr[1] "" 100 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. `msgid`s here are often extracted from 2 | ## source code; add new translations manually only if they're dynamic 3 | ## translations that can't be statically extracted. Run `mix 4 | ## gettext.extract` to bring this file up to date. Leave `msgstr`s empty as 5 | ## changing them here as no effect; edit them in PO (`.po`) files instead. 6 | 7 | ## From Ecto.Changeset.cast/4 8 | msgid "can't be blank" 9 | msgstr "" 10 | 11 | ## From Ecto.Changeset.unique_constraint/3 12 | msgid "has already been taken" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.put_change/3 16 | msgid "is invalid" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.validate_format/3 20 | msgid "has invalid format" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_subset/3 24 | msgid "has an invalid entry" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_exclusion/3 28 | msgid "is reserved" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_confirmation/3 32 | msgid "does not match confirmation" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.no_assoc_constraint/3 36 | msgid "is still associated to this entry" 37 | msgstr "" 38 | 39 | msgid "are still associated to this entry" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.validate_length/3 43 | msgid "should be %{count} character(s)" 44 | msgid_plural "should be %{count} character(s)" 45 | msgstr[0] "" 46 | msgstr[1] "" 47 | 48 | msgid "should have %{count} item(s)" 49 | msgid_plural "should have %{count} item(s)" 50 | msgstr[0] "" 51 | msgstr[1] "" 52 | 53 | msgid "should be at least %{count} character(s)" 54 | msgid_plural "should be at least %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have at least %{count} item(s)" 59 | msgid_plural "should have at least %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at most %{count} character(s)" 64 | msgid_plural "should be at most %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at most %{count} item(s)" 69 | msgid_plural "should have at most %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | ## From Ecto.Changeset.validate_number/3 74 | msgid "must be less than %{count}" 75 | msgid_plural "must be less than %{count}" 76 | msgstr[0] "" 77 | msgstr[1] "" 78 | 79 | msgid "must be greater than %{count}" 80 | msgid_plural "must be greater than %{count}" 81 | msgstr[0] "" 82 | msgstr[1] "" 83 | 84 | msgid "must be less than or equal to %{count}" 85 | msgid_plural "must be less than or equal to %{count}" 86 | msgstr[0] "" 87 | msgstr[1] "" 88 | 89 | msgid "must be greater than or equal to %{count}" 90 | msgid_plural "must be greater than or equal to %{count}" 91 | msgstr[0] "" 92 | msgstr[1] "" 93 | 94 | msgid "must be equal to %{count}" 95 | msgid_plural "must be equal to %{count}" 96 | msgstr[0] "" 97 | msgstr[1] "" 98 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160129095239_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :first_name, :string, null: false 7 | add :last_name, :string 8 | add :email, :string, null: false 9 | add :encrypted_password, :string, null: false 10 | 11 | timestamps 12 | end 13 | 14 | create unique_index(:users, [:email]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160201142815_create_workspace.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Repo.Migrations.CreateWorkspace do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:workspaces) do 6 | add :name, :string, null: false 7 | add :user_id, references(:users, on_delete: :delete_all), null: false 8 | 9 | timestamps 10 | end 11 | 12 | create index(:workspaces, [:user_id]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160203095715_create_workspace_user.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Repo.Migrations.CreateWorkspaceUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:workspace_users) do 6 | add :workspace_id, references(:workspaces, on_delete: :delete_all), null: false 7 | add :user_id, references(:users, on_delete: :delete_all), null: false 8 | 9 | timestamps 10 | end 11 | 12 | create index(:workspace_users, [:workspace_id]) 13 | create index(:workspace_users, [:user_id]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160203095815_create_time_entry.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Repo.Migrations.CreateTimeEntry do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:time_entries) do 6 | add :description, :text 7 | add :started_at, :datetime, null: false 8 | add :stopped_at, :datetime 9 | add :restarted_at, :datetime 10 | add :duration, :integer, default: 0 11 | 12 | add :workspace_id, references(:workspaces, on_delete: :delete_all), null: true 13 | add :user_id, references(:users, on_delete: :delete_all), null: false 14 | 15 | timestamps 16 | end 17 | 18 | create index(:time_entries, [:workspace_id]) 19 | create index(:time_entries, [:user_id]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # PhoenixToggl.Repo.insert!(%PhoenixToggl.SomeModel{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | alias PhoenixToggl.{Repo, User, Workspace, TimeEntry, TimeEntryActions} 14 | alias Timex.{Date, Time} 15 | 16 | Repo.delete_all User 17 | 18 | workspace = %User{} 19 | |> User.changeset(%{first_name: "Ricardo", email: "bigardone@gmail.com", password: "12345678"}) 20 | |> Repo.insert! 21 | |> Ecto.build_assoc(:owned_workspaces) 22 | |> Workspace.changeset(%{name: "Default"}) 23 | |> Repo.insert! 24 | 25 | 26 | number_of_days = 7 27 | 28 | now = Date.now 29 | start_date = now 30 | |> Date.subtract(Time.to_timestamp(number_of_days - 1, :days)) 31 | 32 | 33 | for day_number <- 0..(number_of_days - 1) do 34 | started_at = start_date 35 | |> Date.add(Time.to_timestamp(day_number, :days)) 36 | 37 | offset = Enum.random(120..480) 38 | 39 | stopped_at = started_at 40 | |> Date.add(Time.to_timestamp(offset, :mins)) 41 | 42 | %{ 43 | description: "Task #{day_number + 1}", 44 | user_id: workspace.user_id, 45 | started_at: started_at 46 | } 47 | |> TimeEntryActions.start 48 | |> TimeEntryActions.stop(stopped_at) 49 | end 50 | -------------------------------------------------------------------------------- /test/actions/time_entry_actions_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimeEntryActionsTest do 2 | use PhoenixToggl.ModelCase, async: true 3 | 4 | alias PhoenixToggl.{TimeEntryActions, TimeEntry} 5 | alias Timex.Date 6 | 7 | setup do 8 | user = create(:user) 9 | 10 | valid_attributes = %{ 11 | name: "Default", 12 | user_id: user.id, 13 | started_at: Date.now 14 | } 15 | 16 | {:ok, valid_attributes: valid_attributes} 17 | end 18 | 19 | test "start returns error when existing active TimeEntry", %{valid_attributes: valid_attributes} do 20 | %TimeEntry{} 21 | |> TimeEntry.start(valid_attributes) 22 | |> Repo.insert! 23 | 24 | assert TimeEntryActions.start(valid_attributes) == {:error, :active_time_entry_exists} 25 | end 26 | 27 | test "start", %{valid_attributes: valid_attributes} do 28 | time_entry = TimeEntryActions.start(valid_attributes) 29 | 30 | refute time_entry.started_at == nil 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/channels/user_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.UserChannelTest do 2 | use PhoenixToggl.ChannelCase, async: true 3 | 4 | alias PhoenixToggl.{UserSocket, TimerMonitor, TimeEntry} 5 | alias Timex.Date 6 | 7 | setup do 8 | user = create(:user) 9 | 10 | {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token) 11 | {:ok, socket} = connect(UserSocket, %{"token" => jwt}) 12 | 13 | {:ok, socket: socket, user: user} 14 | end 15 | 16 | test "fails to join for an invalid user", %{socket: socket} do 17 | assert {:error, _} = join(socket, "users:99999999") 18 | end 19 | 20 | test "after joining with no active timer", %{socket: socket, user: user} do 21 | {:ok, _, socket} = join(socket, "users:#{user.id}") 22 | 23 | refute Enum.member?(socket.assigns, :time_entry) 24 | end 25 | 26 | test "after joining with active timer", %{socket: socket, user: user} do 27 | workspace = create(:workspace, user_id: user.id) 28 | 29 | attributes = %{ 30 | description: "Default", 31 | workspace_id: workspace.id, 32 | user_id: user.id, 33 | started_at: Date.now 34 | } 35 | 36 | time_entry = %TimeEntry{} 37 | |> TimeEntry.start(attributes) 38 | |> Repo.insert! 39 | 40 | TimerMonitor.create(user.id) 41 | TimerMonitor.start(user.id, time_entry.id, Date.now) 42 | 43 | {:ok, _, socket} = join(socket, "users:#{user.id}") 44 | 45 | assert time_entry.id == socket.assigns.time_entry.id 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/channels/workspace_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.WorkspaceChannelTest do 2 | use PhoenixToggl.ChannelCase, async: true 3 | 4 | alias PhoenixToggl.{UserSocket, WorkspaceMonitor} 5 | 6 | setup do 7 | user = create(:user) 8 | workspace = create(:workspace, user_id: user.id) 9 | 10 | {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token) 11 | 12 | {:ok, socket} = connect(UserSocket, %{"token" => jwt}) 13 | 14 | {:ok, socket: socket, user: user, workspace: workspace} 15 | end 16 | 17 | test "fails to join invalid workspace", %{socket: socket} do 18 | assert {:error, _} = join(socket, "workspaces:9999999") 19 | end 20 | 21 | test "after joining", %{socket: socket, user: user, workspace: workspace} do 22 | {:ok, _, socket} = join(socket, "workspaces:#{workspace.id}") 23 | 24 | assert socket.assigns[:workspace] == workspace 25 | assert Enum.member?(WorkspaceMonitor.members(workspace.id), user.id) 26 | end 27 | 28 | test "when leaving the channel", %{socket: socket, user: user, workspace: workspace} do 29 | {:ok, _, socket} = join(socket, "workspaces:#{workspace.id}") 30 | WorkspaceMonitor.join(workspace.id, 1234) 31 | 32 | assert Enum.member?(WorkspaceMonitor.members(workspace.id), user.id) 33 | 34 | Process.unlink(socket.channel_pid) 35 | ref = leave(socket) 36 | 37 | assert_reply ref, :ok 38 | 39 | refute Enum.member?(WorkspaceMonitor.members(workspace.id), user.id) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.PageControllerTest do 2 | use PhoenixToggl.ConnCase, async: true 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get conn, "/" 6 | assert html_response(conn, 200) =~ "Phoenix Toggl" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/integration/sign_in_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.SignInTest do 2 | use PhoenixToggl.IntegrationCase 3 | 4 | @tag :integration 5 | test "GET /" do 6 | navigate_to "/" 7 | 8 | assert element_displayed?({:id, "sign_in_form"}) 9 | assert page_title =~ "Sign in" 10 | end 11 | 12 | @tag :integration 13 | test "Sign in with wrong email/password" do 14 | navigate_to "/" 15 | 16 | assert element_displayed?({:id, "sign_in_form"}) 17 | 18 | sign_in_form = find_element(:id, "sign_in_form") 19 | 20 | sign_in_form 21 | |> find_within_element(:id, "user_email") 22 | |> fill_field("incorrect@email.com") 23 | 24 | sign_in_form 25 | |> find_within_element(:id, "user_password") 26 | |> fill_field("foo") 27 | 28 | sign_in_form 29 | |> find_within_element(:css, "button") 30 | |> click 31 | 32 | assert element_displayed?({:class, "error"}) 33 | 34 | assert page_source =~ "Invalid email or password" 35 | end 36 | 37 | @tag :integration 38 | test "Sign in with existing email/password" do 39 | user = create_user 40 | 41 | user_sign_in(%{user: user}) 42 | 43 | assert page_source =~ "#{user.first_name}" 44 | assert page_source =~ "Timer" 45 | assert page_source =~ "Reports" 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/integration/sign_up_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.SignUpTest do 2 | use PhoenixToggl.IntegrationCase 3 | 4 | @tag :integration 5 | test "GET /sign_up" do 6 | navigate_to "/sign_up" 7 | 8 | assert page_title =~ "Sign up" 9 | assert element_displayed?({:id, "sign_up_form"}) 10 | end 11 | 12 | @tag :integration 13 | test "Siginig up with correct data" do 14 | navigate_to "/sign_up" 15 | 16 | assert element_displayed?({:id, "sign_up_form"}) 17 | 18 | sign_up_form = find_element(:id, "sign_up_form") 19 | 20 | sign_up_form 21 | |> find_within_element(:id, "user_first_name") 22 | |> fill_field("John") 23 | 24 | sign_up_form 25 | |> find_within_element(:id, "user_email") 26 | |> fill_field("john@doe.com") 27 | 28 | sign_up_form 29 | |> find_within_element(:id, "user_password") 30 | |> fill_field("12345678") 31 | 32 | sign_up_form 33 | |> find_within_element(:css, "button") 34 | |> click 35 | 36 | assert element_displayed?({:id, "authentication_container"}) 37 | 38 | assert page_source =~ "John" 39 | assert page_source =~ "Timer" 40 | assert page_source =~ "Reports" 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/lib/reporter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.RepoterTest do 2 | use PhoenixToggl.ModelCase, async: true 3 | 4 | alias PhoenixToggl.Reports.{Reporter} 5 | alias PhoenixToggl.{TimeEntryActions} 6 | alias Timex.{Date, Time, DateFormat} 7 | 8 | setup do 9 | user = create(:user) 10 | number_of_weeks = 2 11 | 12 | now = Date.now 13 | start_date = now 14 | |> Date.subtract(Time.to_timestamp(number_of_weeks - 1, :weeks)) 15 | |> Date.beginning_of_week(:mon) 16 | end_date = Date.end_of_week(now, :mon) 17 | 18 | for day_number <- 0..((number_of_weeks * 7) - 1) do 19 | started_at = start_date 20 | |> Date.add(Time.to_timestamp(day_number, :days)) 21 | 22 | stopped_at = started_at 23 | |> Date.add(Time.to_timestamp(4, :hours)) 24 | 25 | %{ 26 | name: "Default", 27 | user_id: user.id, 28 | started_at: started_at 29 | } 30 | |> TimeEntryActions.start 31 | |> TimeEntryActions.stop(stopped_at) 32 | end 33 | 34 | {:ok, user: user, start_date: start_date, end_date: end_date, number_of_weeks: number_of_weeks} 35 | end 36 | 37 | test "invalid params" do 38 | assert Reporter.generate(%{}) == {:error, :invalid_params} 39 | assert Reporter.generate() == {:error, :invalid_params} 40 | end 41 | 42 | test "valid params", %{user: user, start_date: start_date, end_date: end_date, number_of_weeks: number_of_weeks} = params do 43 | data = Reporter.generate(params) 44 | 45 | assert data.user_id == user.id 46 | assert data.start_date == start_date |> DateFormat.format!("%Y-%m-%d", :strftime) 47 | assert data.end_date == end_date |> DateFormat.format!("%Y-%m-%d", :strftime) 48 | assert length(data.days) == 7 * number_of_weeks 49 | 50 | for day <- data.days do 51 | assert day.duration == 4 * 3600 52 | end 53 | 54 | assert data.total_duration == 7 * 4 * 3600 * number_of_weeks 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/lib/timer_monitor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimerMonitorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixToggl.TimerMonitor 5 | alias Timex.Date 6 | 7 | setup do 8 | user_id = :crypto.strong_rand_bytes(32) |> Base.encode64() 9 | {:ok, pid} = TimerMonitor.create(user_id) 10 | 11 | {:ok, user_id: user_id, pid: pid} 12 | end 13 | 14 | test "creting same timer twice returns error", %{user_id: user_id} do 15 | assert TimerMonitor.create(user_id) == {:error, :timer_already_exists} 16 | end 17 | 18 | test "starting a new timer", %{user_id: user_id} do 19 | time_entry_id = 1 20 | started_at = Date.now 21 | assert TimerMonitor.start(user_id, time_entry_id, started_at) == %{time_entry_id: time_entry_id, started_at: started_at} 22 | end 23 | 24 | test "starting a new timer more times", %{user_id: user_id} do 25 | time_entry_id = 1 26 | started_at = Date.now 27 | TimerMonitor.start(user_id, time_entry_id, started_at) 28 | assert TimerMonitor.start(user_id, time_entry_id, started_at) == :timer_already_started 29 | end 30 | 31 | test "stopping a timer", %{user_id: user_id} do 32 | assert TimerMonitor.stop(user_id) == :ok 33 | assert TimerMonitor.start(user_id, 1, Date.now) == {:error, :invalid_timer} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/lib/workspace_monitor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.WorkspaceMonitorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixToggl.WorkspaceMonitor 5 | 6 | setup do 7 | workspace_id = :crypto.strong_rand_bytes(32) |> Base.encode64() 8 | {:ok, pid} = WorkspaceMonitor.create(workspace_id) 9 | 10 | {:ok, workspace_id: workspace_id, pid: pid} 11 | end 12 | 13 | test "creating same workspace twice returns error", %{workspace_id: workspace_id} do 14 | assert WorkspaceMonitor.create(workspace_id) == {:error, :workspace_already_exists} 15 | end 16 | 17 | test "user joins a correct workspace", %{workspace_id: workspace_id} do 18 | user_id = 1 19 | 20 | assert WorkspaceMonitor.join(workspace_id, user_id) == :ok 21 | assert Enum.member?(WorkspaceMonitor.members(workspace_id), user_id) 22 | end 23 | 24 | test "user joins a incorrect workspace" do 25 | user_id = 1 26 | 27 | assert WorkspaceMonitor.join(3333, user_id) == {:error, :invalid_workspace} 28 | end 29 | 30 | test "all members", %{workspace_id: workspace_id} do 31 | users = [1, 2, 3, 4, 5] 32 | Enum.each(users, &WorkspaceMonitor.join(workspace_id, &1)) 33 | 34 | assert WorkspaceMonitor.members(workspace_id) == Enum.reverse(users) 35 | end 36 | 37 | test "user leaves the workspace", %{workspace_id: workspace_id} do 38 | users = [1, 2, 3, 4, 5] 39 | Enum.each(users, &WorkspaceMonitor.join(workspace_id, &1)) 40 | 41 | WorkspaceMonitor.leave(workspace_id, 1) 42 | 43 | assert WorkspaceMonitor.members(workspace_id) == users |> List.delete(1) |> Enum.reverse 44 | end 45 | 46 | test "last user leaves", %{workspace_id: workspace_id} do 47 | WorkspaceMonitor.join(workspace_id, 1) 48 | WorkspaceMonitor.leave(workspace_id, 1) 49 | 50 | assert WorkspaceMonitor.members(workspace_id) == {:error, :invalid_workspace} 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/models/time_entry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimeEntryTest do 2 | use PhoenixToggl.ModelCase, async: true 3 | 4 | alias PhoenixToggl.TimeEntry 5 | alias Timex.Date 6 | 7 | setup do 8 | user = create(:user) 9 | 10 | valid_attributes = %{ 11 | name: "Default", 12 | user_id: user.id, 13 | started_at: Date.now 14 | } 15 | 16 | time_entry = %TimeEntry{} 17 | |> TimeEntry.start(valid_attributes) 18 | |> Repo.insert! 19 | 20 | {:ok, valid_attributes: valid_attributes, time_entry: time_entry} 21 | end 22 | 23 | test "changeset with valid attributes", %{valid_attributes: valid_attributes} do 24 | changeset = TimeEntry.changeset(%TimeEntry{}, valid_attributes) 25 | assert changeset.valid? 26 | end 27 | 28 | test "changeset with invalid attributes" do 29 | changeset = TimeEntry.changeset(%TimeEntry{}, %{}) 30 | refute changeset.valid? 31 | end 32 | 33 | test "stop", %{time_entry: time_entry} do 34 | stopped_at = Date.now 35 | |> Date.shift(mins: 2) 36 | 37 | time_entry = time_entry 38 | |> TimeEntry.stop(stopped_at) 39 | |> Repo.update! 40 | 41 | refute time_entry.stopped_at == nil 42 | assert time_entry.duration > 0 43 | assert time_entry.duration == Date.diff(time_entry.started_at, stopped_at, :secs) 44 | end 45 | 46 | test "restart", %{time_entry: time_entry} do 47 | time_entry = time_entry 48 | |> TimeEntry.stop 49 | |> Repo.update! 50 | |> TimeEntry.restart 51 | |> Repo.update! 52 | 53 | refute time_entry.restarted_at == nil 54 | assert time_entry.stopped_at == nil 55 | end 56 | 57 | test "restart with no restart_at", %{time_entry: time_entry} do 58 | changeset = TimeEntry.restart_changeset(time_entry, %{}) 59 | 60 | refute changeset.valid? 61 | assert {:restarted_at, "can't be blank"} in changeset.errors 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/models/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.UserTest do 2 | use PhoenixToggl.ModelCase, async: true 3 | 4 | alias PhoenixToggl.User 5 | 6 | @valid_attrs %{password: "some content", email: "foo@bar.com", first_name: "some content", last_name: "some content"} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = User.changeset(%User{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = User.changeset(%User{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/models/workspace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.WorkspaceTest do 2 | use PhoenixToggl.ModelCase, async: true 3 | 4 | alias PhoenixToggl.Workspace 5 | 6 | setup do 7 | user = create(:user) 8 | 9 | {:ok, user: user} 10 | end 11 | 12 | test "changeset with valid attributes", %{user: user} do 13 | changeset = Workspace.changeset(%Workspace{}, %{name: "Deafult", user_id: user.id}) 14 | assert changeset.valid? 15 | end 16 | 17 | test "changeset with invalid attributes" do 18 | changeset = Workspace.changeset(%Workspace{}, %{}) 19 | refute changeset.valid? 20 | end 21 | 22 | test "creates automatically a workspace_user", %{user: user} do 23 | changeset = Workspace.changeset(%Workspace{}, %{name: "Deafult", user_id: user.id}) 24 | 25 | {:ok, workspace} = Repo.insert changeset 26 | workspace = Repo.preload workspace, :users 27 | 28 | assert length(workspace.users) == 1 29 | 30 | workspace_user = List.first(workspace.users) 31 | 32 | assert workspace_user.id == user.id 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | alias PhoenixToggl.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query, only: [from: 1, from: 2] 27 | import PhoenixToggl.Factory 28 | 29 | # The default endpoint for testing 30 | @endpoint PhoenixToggl.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | unless tags[:async] do 36 | Ecto.Adapters.SQL.restart_test_transaction(PhoenixToggl.Repo, []) 37 | end 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | 23 | alias PhoenixToggl.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query, only: [from: 1, from: 2] 27 | 28 | import PhoenixToggl.Router.Helpers 29 | import PhoenixToggl.Factory 30 | 31 | # The default endpoint for testing 32 | @endpoint PhoenixToggl.Endpoint 33 | end 34 | end 35 | 36 | setup tags do 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.restart_test_transaction(PhoenixToggl.Repo, []) 39 | end 40 | 41 | {:ok, conn: Phoenix.ConnTest.conn()} 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Factory do 2 | use ExMachina.Ecto, repo: PhoenixToggl.Repo 3 | 4 | alias PhoenixToggl.{User, Workspace} 5 | 6 | def factory(:user) do 7 | %User{ 8 | first_name: sequence(:first_name, &"First #{&1}"), 9 | last_name: sequence(:last_name, &"Last #{&1}"), 10 | email: sequence(:email, &"email-#{&1}@foo.com"), 11 | encrypted_password: "12345678" 12 | } 13 | end 14 | 15 | def factory(:workspace) do 16 | %Workspace{ 17 | name: sequence(:name, &"Workspace #{&1}") 18 | } 19 | end 20 | 21 | def with_workspace(user) do 22 | create(:workspace, user_id: user.id) 23 | user 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/integration_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.IntegrationCase do 2 | use ExUnit.CaseTemplate 3 | use Hound.Helpers 4 | import PhoenixToggl.Factory 5 | 6 | alias PhoenixToggl.{Repo, User} 7 | 8 | using do 9 | quote do 10 | use Hound.Helpers 11 | 12 | import Ecto, only: [build_assoc: 2] 13 | import Ecto.Model 14 | import Ecto.Query, only: [from: 2] 15 | import PhoenixToggl.Router.Helpers 16 | import PhoenixToggl.Factory 17 | import PhoenixToggl.IntegrationCase 18 | 19 | alias PhoenixToggl.Repo 20 | 21 | # The default endpoint for testing 22 | @endpoint PhoenixToggl.Endpoint 23 | 24 | hound_session 25 | end 26 | end 27 | 28 | setup tags do 29 | unless tags[:async] do 30 | Ecto.Adapters.SQL.restart_test_transaction(PhoenixToggl.Repo, []) 31 | end 32 | 33 | :ok 34 | end 35 | 36 | def create_user do 37 | build(:user) 38 | |> User.changeset(%{password: "12345678"}) 39 | |> Repo.insert! 40 | end 41 | 42 | def user_sign_in(%{user: user}) do 43 | navigate_to "/" 44 | 45 | sign_in_form = find_element(:id, "sign_in_form") 46 | 47 | sign_in_form 48 | |> find_within_element(:id, "user_email") 49 | |> fill_field(user.email) 50 | 51 | sign_in_form 52 | |> find_within_element(:id, "user_password") 53 | |> fill_field(user.password) 54 | 55 | sign_in_form 56 | |> find_within_element(:css, "button") 57 | |> click 58 | 59 | assert element_displayed?({:id, "authentication_container"}) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias PhoenixToggl.Repo 20 | 21 | import Ecto 22 | import Ecto.Model, except: [build: 2] 23 | import Ecto.Changeset 24 | import Ecto.Query, only: [from: 1, from: 2] 25 | import PhoenixToggl.ModelCase 26 | 27 | import PhoenixToggl.Factory 28 | end 29 | end 30 | 31 | setup tags do 32 | unless tags[:async] do 33 | Ecto.Adapters.SQL.restart_test_transaction(PhoenixToggl.Repo, []) 34 | end 35 | 36 | :ok 37 | end 38 | 39 | @doc """ 40 | Helper for returning list of errors in model when passed certain data. 41 | 42 | ## Examples 43 | 44 | Given a User model that lists `:name` as a required field and validates 45 | `:password` to be safe, it would return: 46 | 47 | iex> errors_on(%User{}, %{password: "password"}) 48 | [password: "is unsafe", name: "is blank"] 49 | 50 | You could then write your assertion like: 51 | 52 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 53 | 54 | You can also create the changeset manually and retrieve the errors 55 | field directly: 56 | 57 | iex> changeset = User.changeset(%User{}, password: "password") 58 | iex> {:password, "is unsafe"} in changeset.errors 59 | true 60 | """ 61 | def errors_on(model, data) do 62 | model.__struct__.changeset(model, data).errors 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _} = Application.ensure_all_started(:ex_machina) 2 | 3 | Application.ensure_all_started(:hound) 4 | 5 | ExUnit.start 6 | 7 | Mix.Task.run "ecto.create", ~w(-r PhoenixToggl.Repo --quiet) 8 | Mix.Task.run "ecto.migrate", ~w(-r PhoenixToggl.Repo --quiet) 9 | Ecto.Adapters.SQL.begin_test_transaction(PhoenixToggl.Repo) 10 | -------------------------------------------------------------------------------- /test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.ErrorViewTest do 2 | use PhoenixToggl.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(PhoenixToggl.ErrorView, "404.html", []) == 9 | "Page not found" 10 | end 11 | 12 | test "render 500.html" do 13 | assert render_to_string(PhoenixToggl.ErrorView, "500.html", []) == 14 | "Server internal error" 15 | end 16 | 17 | test "render any other" do 18 | assert render_to_string(PhoenixToggl.ErrorView, "505.html", []) == 19 | "Server internal error" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.LayoutViewTest do 2 | use PhoenixToggl.ConnCase, async: true 3 | end -------------------------------------------------------------------------------- /test/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.PageViewTest do 2 | use PhoenixToggl.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /web/actions/time_entry_actions.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimeEntryActions do 2 | alias PhoenixToggl.{Repo, TimeEntry} 3 | 4 | @doc """ 5 | Returns the active TimeEntry for a user from the database 6 | """ 7 | def get_active_for_user(user_id) do 8 | TimeEntry 9 | |> TimeEntry.active_for_user(user_id) 10 | |> Repo.one 11 | end 12 | 13 | @doc """ 14 | Creates a new TimeEntry 15 | """ 16 | def start(%{user_id: user_id} = time_entry_params) do 17 | case get_active_for_user(user_id) do 18 | nil -> 19 | perform_start(time_entry_params) 20 | _time_entry_params -> 21 | {:error, :active_time_entry_exists} 22 | end 23 | end 24 | 25 | def stop(time_entry, stopped_at) do 26 | time_entry 27 | |> TimeEntry.stop(stopped_at) 28 | |> Repo.update! 29 | end 30 | 31 | def restart(time_entry, restarted_at) do 32 | case get_active_for_user(time_entry.user_id) do 33 | nil -> 34 | perform_restart(time_entry, restarted_at) 35 | _time_entry_params -> 36 | {:error, :active_time_entry_exists} 37 | end 38 | end 39 | 40 | def update(time_entry, params) do 41 | time_entry 42 | |> TimeEntry.changeset(params) 43 | |> Repo.update! 44 | end 45 | 46 | def discard(time_entry) do 47 | time_entry 48 | |> Repo.delete! 49 | end 50 | 51 | def delete_all(time_entries, ids) do 52 | time_entries 53 | |> TimeEntry.by_ids(ids) 54 | |> Repo.delete_all 55 | end 56 | 57 | defp perform_start(time_entry_params) do 58 | %TimeEntry{} 59 | |> TimeEntry.start(time_entry_params) 60 | |> Repo.insert! 61 | end 62 | 63 | defp perform_restart(time_entry, restarted_at) do 64 | time_entry 65 | |> TimeEntry.restart(restarted_at) 66 | |> Repo.update! 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /web/channels/user_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.UserChannel do 2 | use PhoenixToggl.Web, :channel 3 | 4 | alias PhoenixToggl.{TimerMonitor, TimeEntry, TimeEntryActions} 5 | alias PhoenixToggl.Reports.Reporter 6 | 7 | def join("users:" <> user_id, _params, socket) do 8 | user_id = String.to_integer(user_id) 9 | current_user = socket.assigns.current_user 10 | 11 | if user_id == current_user.id do 12 | socket = set_active_time_entry(socket, user_id) 13 | 14 | {:ok, %{time_entry: socket.assigns.time_entry}, socket} 15 | else 16 | {:error, %{reason: "Invalid user"}} 17 | end 18 | end 19 | 20 | def handle_in("time_entry:current", _params, socket), do: {:reply, {:ok, socket.assigns.time_entry}, socket} 21 | 22 | def handle_in("time_entry:start", 23 | %{ 24 | "started_at" => started_at, 25 | "description" => description, 26 | "workspace_id" => workspace_id 27 | }, socket) do 28 | 29 | current_user = socket.assigns.current_user 30 | 31 | attributes = %{ 32 | description: description, 33 | workspace_id: workspace_id, 34 | user_id: current_user.id, 35 | started_at: started_at 36 | } 37 | 38 | case TimeEntryActions.start(attributes) do 39 | {:error, :active_time_entry_exists} -> 40 | {:reply, :error, socket} 41 | time_entry -> 42 | TimerMonitor.create(current_user.id) 43 | TimerMonitor.start(current_user.id, time_entry.id, time_entry.started_at) 44 | 45 | socket = assign(socket, :time_entry, time_entry) 46 | 47 | {:reply, {:ok, time_entry}, socket} 48 | end 49 | end 50 | 51 | def handle_in("time_entry:stop", 52 | %{ 53 | "id" => _id, 54 | "stopped_at" => stopped_at, 55 | }, socket) do 56 | current_user = socket.assigns.current_user 57 | {:ok, stopped_at} = Timex.DateFormat.parse(stopped_at, "{ISO}") 58 | 59 | time_entry = socket.assigns.time_entry 60 | |> TimeEntryActions.stop(stopped_at) 61 | 62 | TimerMonitor.stop(current_user.id) 63 | 64 | {:reply, {:ok, time_entry}, assign(socket, :time_entry, nil)} 65 | end 66 | 67 | def handle_in("time_entry:restart", 68 | %{ 69 | "id" => id, 70 | "restarted_at" => restarted_at, 71 | }, socket) do 72 | current_user = socket.assigns.current_user 73 | 74 | {:ok, restarted_at} = Timex.DateFormat.parse(restarted_at, "{ISO}") 75 | 76 | current_user 77 | |> assoc(:time_entries) 78 | |> Repo.get!(id) 79 | |> TimeEntryActions.restart(restarted_at) 80 | |> case do 81 | {:error, :active_time_entry_exists} -> 82 | {:reply, :error, socket} 83 | time_entry -> 84 | TimerMonitor.create(current_user.id) 85 | TimerMonitor.start(current_user.id, time_entry.id, time_entry.restarted_at) 86 | 87 | socket = assign(socket, :time_entry, time_entry) 88 | 89 | {:reply, {:ok, time_entry}, socket} 90 | end 91 | end 92 | 93 | def handle_in("time_entry:update", %{"id" => id, "description" => _description} = params, socket) do 94 | current_user = socket.assigns.current_user 95 | 96 | time_entry = current_user 97 | |> assoc(:time_entries) 98 | |> Repo.get!(id) 99 | |> TimeEntryActions.update(params) 100 | 101 | {:reply, {:ok, time_entry}, socket} 102 | end 103 | def handle_in("time_entry:update", %{"description" => _description} = params, socket) do 104 | time_entry = socket.assigns.time_entry 105 | |> TimeEntryActions.update(params) 106 | 107 | {:reply, {:ok, time_entry}, assign(socket, :time_entry, time_entry)} 108 | end 109 | 110 | def handle_in("time_entry:discard", %{"id" => _id}, socket) do 111 | current_user = socket.assigns.current_user 112 | 113 | time_entry = socket.assigns.time_entry 114 | |> TimeEntryActions.discard 115 | 116 | TimerMonitor.stop(current_user.id) 117 | 118 | {:reply, {:ok, time_entry}, assign(socket, :time_entry, nil)} 119 | end 120 | 121 | def handle_in("time_entry:delete", %{"id" => ids}, socket) when is_list(ids) do 122 | current_user = socket.assigns.current_user 123 | 124 | current_user 125 | |> assoc(:time_entries) 126 | |> TimeEntryActions.delete_all(ids) 127 | 128 | {:reply, {:ok, %{ids: ids}}, socket} 129 | end 130 | def handle_in("time_entry:delete", %{"id" => id}, socket) when is_number(id) do 131 | current_user = socket.assigns.current_user 132 | 133 | time_entry = current_user 134 | |> assoc(:time_entries) 135 | |> Repo.get!(id) 136 | |> TimeEntryActions.discard 137 | 138 | {:reply, {:ok, time_entry}, socket} 139 | end 140 | def handle_in("time_entry:delete", _, socket), do: {:reply, {:error, %{reason: "No time entries selected"}}, socket} 141 | 142 | def handle_in("reports:generate", %{"number_of_weeks" => number_of_weeks}, socket) do 143 | current_user = socket.assigns.current_user 144 | 145 | data = %{user: current_user, number_of_weeks: number_of_weeks} 146 | |> Reporter.generate 147 | 148 | {:reply, {:ok, data}, socket} 149 | end 150 | 151 | # In case there's an existing time entry monitor running it 152 | # assigns its TimeEntry to the socket. Otherwise it will try to 153 | # find an active TimeEntry in the database and start a new monitor 154 | # with it. 155 | defp set_active_time_entry(socket, user_id) do 156 | case TimerMonitor.info(user_id) do 157 | %{time_entry_id: time_entry_id} -> 158 | time_entry = Repo.get! TimeEntry, time_entry_id 159 | 160 | assign(socket, :time_entry, time_entry) 161 | {:error, :invalid_timer} -> 162 | case TimeEntryActions.get_active_for_user(user_id) do 163 | nil -> 164 | assign(socket, :time_entry, nil) 165 | time_entry -> 166 | TimerMonitor.create(user_id) 167 | TimerMonitor.start(user_id, time_entry.id, time_entry.restarted_at || time_entry.started_at) 168 | assign(socket, :time_entry, time_entry) 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.UserSocket do 2 | use Phoenix.Socket 3 | 4 | alias PhoenixToggl.{GuardianSerializer} 5 | 6 | # Channels 7 | channel "users:*", PhoenixToggl.UserChannel 8 | channel "workspaces:*", PhoenixToggl.WorkspaceChannel 9 | 10 | # Transports 11 | transport :websocket, Phoenix.Transports.WebSocket 12 | 13 | def connect(%{"token" => token}, socket) do 14 | case Guardian.decode_and_verify(token) do 15 | {:ok, claims} -> 16 | case GuardianSerializer.from_token(claims["sub"]) do 17 | {:ok, user} -> 18 | {:ok, assign(socket, :current_user, user)} 19 | {:error, _reason} -> 20 | :error 21 | end 22 | {:error, _reason} -> 23 | :error 24 | end 25 | end 26 | 27 | def connect(_params, _socket), do: :error 28 | 29 | def id(socket), do: "users_socket:#{socket.assigns.current_user.id}" 30 | end 31 | -------------------------------------------------------------------------------- /web/channels/workspace_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.WorkspaceChannel do 2 | use PhoenixToggl.Web, :channel 3 | 4 | alias PhoenixToggl.{WorkspaceMonitor} 5 | 6 | def join("workspaces:" <> workspace_id, _payload, socket) do 7 | current_user = socket.assigns.current_user 8 | 9 | current_user 10 | |> Repo.preload(:owned_workspaces) 11 | |> assoc(:owned_workspaces) 12 | |> Repo.get(workspace_id) 13 | |> case do 14 | nil -> 15 | {:error, %{}} 16 | workspace -> 17 | WorkspaceMonitor.create(workspace.id) 18 | WorkspaceMonitor.join(workspace.id, current_user.id) 19 | 20 | socket = assign(socket, :workspace, workspace) 21 | {:ok, socket} 22 | end 23 | end 24 | 25 | def terminate(_reason, socket) do 26 | workspace_id = socket.assigns.workspace.id 27 | user_id = socket.assigns.current_user.id 28 | 29 | WorkspaceMonitor.leave(workspace_id, user_id) 30 | 31 | :ok 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /web/controllers/api/v1/current_user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.CurrentUserController do 2 | use PhoenixToggl.Web, :controller 3 | 4 | plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixToggl.SessionController 5 | 6 | def show(conn, _) do 7 | user = Guardian.Plug.current_resource(conn) 8 | 9 | conn 10 | |> put_status(:ok) 11 | |> render("show.json", user: user) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /web/controllers/api/v1/registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.RegistrationController do 2 | use PhoenixToggl.Web, :controller 3 | 4 | alias PhoenixToggl.{Repo, User} 5 | 6 | plug :scrub_params, "user" when action in [:create] 7 | 8 | def create(conn, %{"user" => user_params}) do 9 | changeset = User.changeset(%User{}, user_params) 10 | 11 | case Repo.insert(changeset) do 12 | {:ok, user} -> 13 | {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token) 14 | 15 | conn 16 | |> put_status(:created) 17 | |> render(PhoenixToggl.SessionView, "show.json", jwt: jwt, user: user) 18 | 19 | {:error, changeset} -> 20 | conn 21 | |> put_status(:unprocessable_entity) 22 | |> render("error.json", changeset: changeset) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /web/controllers/api/v1/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.SessionController do 2 | use PhoenixToggl.Web, :controller 3 | 4 | plug :scrub_params, "session" when action in [:create] 5 | 6 | def create(conn, %{"session" => session_params}) do 7 | case PhoenixToggl.Session.authenticate(session_params) do 8 | {:ok, user} -> 9 | {:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token) 10 | 11 | conn 12 | |> put_status(:created) 13 | |> render("show.json", jwt: jwt, user: user) 14 | 15 | :error -> 16 | conn 17 | |> put_status(:unprocessable_entity) 18 | |> render("error.json") 19 | end 20 | end 21 | 22 | def delete(conn, _) do 23 | {:ok, claims} = Guardian.Plug.claims(conn) 24 | 25 | conn 26 | |> Guardian.Plug.current_token 27 | |> Guardian.revoke!(claims) 28 | 29 | conn 30 | |> render("delete.json") 31 | end 32 | 33 | def unauthenticated(conn, _params) do 34 | conn 35 | |> put_status(:forbidden) 36 | |> render(PhoenixToggl.SessionView, "forbidden.json", error: "Not Authenticated") 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /web/controllers/api/v1/time_entry_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimeEntryController do 2 | use PhoenixToggl.Web, :controller 3 | 4 | alias PhoenixToggl.{Repo, TimeEntry} 5 | 6 | plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController 7 | 8 | def index(conn, _params) do 9 | current_user = Guardian.Plug.current_resource(conn) 10 | 11 | time_entries = current_user 12 | |> assoc(:time_entries) 13 | |> TimeEntry.not_active_for_user(current_user.id) 14 | |> TimeEntry.sorted 15 | |> Repo.all 16 | 17 | json conn, %{time_entries: time_entries} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.PageController do 2 | use PhoenixToggl.Web, :controller 3 | 4 | def index(conn, _params) do 5 | render conn, "index.html" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](http://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import PhoenixToggl.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](http://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :phoenix_toggl 24 | end 25 | -------------------------------------------------------------------------------- /web/helpers/session.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Session do 2 | alias PhoenixToggl.{Repo, User} 3 | 4 | def authenticate(%{"email" => email, "password" => password}) do 5 | user = Repo.get_by(User, email: String.downcase(email)) 6 | 7 | case check_password(user, password) do 8 | true -> {:ok, user} 9 | _ -> :error 10 | end 11 | end 12 | 13 | defp check_password(user, password) do 14 | case user do 15 | nil -> Comeonin.Bcrypt.dummy_checkpw() 16 | _ -> Comeonin.Bcrypt.checkpw(password, user.encrypted_password) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /web/models/time_entry.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.TimeEntry do 2 | use PhoenixToggl.Web, :model 3 | use Ecto.Model.Callbacks 4 | 5 | alias PhoenixToggl.{Workspace, User} 6 | alias Timex.Ecto.DateTime 7 | alias Timex.Date 8 | 9 | @derive {Poison.Encoder, only: [:id, :description, :started_at, :stopped_at, :restarted_at, :duration, :updated_at]} 10 | 11 | schema "time_entries" do 12 | field :description, :string 13 | field :started_at, DateTime 14 | field :stopped_at, DateTime 15 | field :restarted_at, DateTime 16 | field :duration, :integer 17 | 18 | belongs_to :workspace, Workspace 19 | belongs_to :user, User 20 | 21 | timestamps 22 | end 23 | 24 | @required_fields ~w(started_at user_id) 25 | @restart_required_fields ~w(restarted_at) 26 | @optional_fields ~w(description stopped_at duration workspace_id restarted_at) 27 | 28 | @doc """ 29 | Creates a changeset based on the `model` and `params`. 30 | 31 | If no params are provided, an invalid changeset is returned 32 | with no validation performed. 33 | """ 34 | def changeset(model, params \\ :empty) do 35 | model 36 | |> cast(params, @required_fields, @optional_fields) 37 | |> foreign_key_constraint(:workspace_id) 38 | |> foreign_key_constraint(:user_id) 39 | end 40 | 41 | @doc """ 42 | Creates a default changeset and sets the first time_range 43 | """ 44 | def start_changeset(model, params \\ :empty) do 45 | model 46 | |> changeset(params) 47 | |> put_change(:duration, 0) 48 | end 49 | 50 | @doc """ 51 | Creates a default changeset and calculates the duration depending on 52 | if the TimeEntry has been restarted or not. 53 | """ 54 | def stop_changeset(model, params \\ :empty) do 55 | duration = case model.restarted_at do 56 | nil -> 57 | Date.diff(model.started_at, params.stopped_at, :secs) 58 | restarted_at -> 59 | model.duration + Date.diff(restarted_at, params.stopped_at, :secs) 60 | end 61 | 62 | model 63 | |> changeset(params) 64 | |> put_change(:duration, duration) 65 | end 66 | 67 | @doc """ 68 | Creates a default changeset and sets the stop key value 69 | on the last time_range 70 | """ 71 | def restart_changeset(model, params \\ :empty) do 72 | model 73 | |> changeset(params) 74 | |> cast(params, @restart_required_fields, @optional_fields) 75 | |> put_change(:stopped_at, nil) 76 | end 77 | 78 | @doc """ 79 | Returns a start_changeset 80 | """ 81 | def start(time_entry, params) do 82 | time_entry 83 | |> start_changeset(params) 84 | end 85 | 86 | @doc """ 87 | Returns a stop_changeset 88 | """ 89 | def stop(time_entry, date_time \\ Date.now) do 90 | time_entry 91 | |> stop_changeset(%{stopped_at: date_time}) 92 | end 93 | 94 | @doc """ 95 | Returns a restart_changeset 96 | """ 97 | def restart(time_entry, date_time \\ Date.now) do 98 | time_entry 99 | |> restart_changeset(%{restarted_at: date_time}) 100 | end 101 | 102 | def active_for_user(query, user_id) do 103 | from t in query, 104 | where: t.user_id == ^user_id and is_nil(t.stopped_at) 105 | end 106 | 107 | def not_active_for_user(query, user_id) do 108 | from t in query, 109 | where: t.user_id == ^user_id and not(is_nil(t.stopped_at)) 110 | end 111 | 112 | def sorted(query) do 113 | from t in query, 114 | order_by: [desc: t.stopped_at] 115 | end 116 | 117 | def total_duration_for_date(query, date) do 118 | from t in query, 119 | select: sum(t.duration), 120 | where: fragment("date(?) = date(?)", t.started_at, type(^date, Ecto.Date)) 121 | end 122 | 123 | def by_ids(query \\ TimeEntry, ids) do 124 | from t in query, 125 | where: t.id in ^ids 126 | end 127 | 128 | # Private functions 129 | ################### 130 | 131 | end 132 | -------------------------------------------------------------------------------- /web/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.User do 2 | use PhoenixToggl.Web, :model 3 | 4 | alias PhoenixToggl.{Workspace, WorkspaceUser, TimeEntry} 5 | 6 | @derive {Poison.Encoder, only: [:id, :first_name, :last_name, :email]} 7 | 8 | schema "users" do 9 | field :first_name, :string 10 | field :last_name, :string 11 | field :email, :string 12 | field :encrypted_password, :string 13 | 14 | field :password, :string, virtual: true 15 | 16 | has_many :owned_workspaces, Workspace 17 | has_many :workspace_users, WorkspaceUser 18 | has_many :workspaces, through: [:workspace_users, :user] 19 | has_many :time_entries, TimeEntry 20 | 21 | timestamps 22 | end 23 | 24 | @required_fields ~w(first_name email password) 25 | @optional_fields ~w(encrypted_password last_name) 26 | 27 | @doc """ 28 | Creates a changeset based on the `model` and `params`. 29 | 30 | If no params are provided, an invalid changeset is returned 31 | with no validation performed. 32 | """ 33 | def changeset(model, params \\ :empty) do 34 | model 35 | |> cast(params, @required_fields, @optional_fields) 36 | |> validate_format(:email, ~r/@/) 37 | |> validate_length(:password, min: 5) 38 | |> unique_constraint(:email, message: "Email already taken") 39 | |> generate_encrypted_password 40 | end 41 | 42 | defp generate_encrypted_password(current_changeset) do 43 | case current_changeset do 44 | %Ecto.Changeset{valid?: true, changes: %{password: password}} -> 45 | put_change(current_changeset, :encrypted_password, Comeonin.Bcrypt.hashpwsalt(password)) 46 | _ -> 47 | current_changeset 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /web/models/workspace.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Workspace do 2 | use PhoenixToggl.Web, :model 3 | use Ecto.Model.Callbacks 4 | 5 | alias __MODULE__ 6 | alias PhoenixToggl.{User, WorkspaceUser, Repo} 7 | 8 | @derive {Poison.Encoder, only: [:id, :name]} 9 | 10 | schema "workspaces" do 11 | field :name, :string 12 | 13 | belongs_to :owner, User, foreign_key: :user_id 14 | has_many :workspace_users, WorkspaceUser 15 | has_many :users, through: [:workspace_users, :user] 16 | 17 | timestamps 18 | end 19 | 20 | @required_fields ~w(name user_id) 21 | @optional_fields ~w() 22 | 23 | @doc """ 24 | Creates a changeset based on the `model` and `params`. 25 | 26 | If no params are provided, an invalid changeset is returned 27 | with no validation performed. 28 | """ 29 | def changeset(model, params \\ :empty) do 30 | model 31 | |> cast(params, @required_fields, @optional_fields) 32 | |> foreign_key_constraint(:user_id) 33 | end 34 | 35 | after_insert Workspace, :insert_workspace_user 36 | 37 | def insert_workspace_user(changeset) do 38 | workspace_id = changeset.model.id 39 | user_id = changeset.model.user_id 40 | workspace_user_changeset = WorkspaceUser.changeset(%WorkspaceUser{}, %{workspace_id: workspace_id, user_id: user_id}) 41 | 42 | Repo.insert!(workspace_user_changeset) 43 | 44 | changeset 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /web/models/workspace_user.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.WorkspaceUser do 2 | use PhoenixToggl.Web, :model 3 | 4 | alias __MODULE__ 5 | alias PhoenixToggl.{Workspace, User} 6 | 7 | schema "workspace_users" do 8 | belongs_to :workspace, Workspace 9 | belongs_to :user, User 10 | 11 | timestamps 12 | end 13 | 14 | @required_fields ~w(user_id workspace_id) 15 | @optional_fields ~w() 16 | 17 | @doc """ 18 | Creates a changeset based on the `model` and `params`. 19 | 20 | If no params are provided, an invalid changeset is returned 21 | with no validation performed. 22 | """ 23 | def changeset(model, params \\ :empty) do 24 | model 25 | |> cast(params, @required_fields, @optional_fields) 26 | |> foreign_key_constraint(:workspace_id) 27 | |> foreign_key_constraint(:user_id) 28 | end 29 | 30 | def find_by_workspace_and_user(query \\ %WorkspaceUser{}, workspace_id, user_id) do 31 | from u in query, 32 | where: u.user_id == ^user_id and u.workspace_id == ^workspace_id 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Router do 2 | use PhoenixToggl.Web, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | end 11 | 12 | pipeline :api do 13 | plug :accepts, ["json"] 14 | plug Guardian.Plug.VerifyHeader 15 | plug Guardian.Plug.LoadResource 16 | end 17 | 18 | scope "/api", PhoenixToggl do 19 | pipe_through :api 20 | 21 | scope "/v1" do 22 | post "/registrations", RegistrationController, :create 23 | 24 | post "/sessions", SessionController, :create 25 | delete "/sessions", SessionController, :delete 26 | 27 | get "/current_user", CurrentUserController, :show 28 | 29 | resources "/time_entries", TimeEntryController, only: [:index] 30 | end 31 | end 32 | 33 | scope "/", PhoenixToggl do 34 | pipe_through :browser # Use the default browser stack 35 | 36 | get "*path", PageController, :index 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /web/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bigardone/phoenix-toggl/dcaa2409ed8b53d3b56dc7ce02cdc4252dd19647/web/static/assets/favicon.ico -------------------------------------------------------------------------------- /web/static/assets/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.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 | -------------------------------------------------------------------------------- /web/static/css-burrito-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "root": { 4 | "pathToSassDirectory": "./", 5 | "sassDirectory": "stylesheets", 6 | "sassImportFile": "application.scss" 7 | }, 8 | "modules": { 9 | "modulesDirectory": "modules", 10 | "modulesImportFile": "_modules.scss", 11 | "moduleFiles": [ 12 | "_example-module.scss" 13 | ] 14 | } 15 | }, 16 | "template": { 17 | "directories": [ 18 | "global", 19 | "libs" 20 | ], 21 | "files": [ 22 | "global/_base.scss", 23 | "global/_layout.scss", 24 | "global/_settings.scss", 25 | "global/_skin.scss", 26 | "global/_typography.scss", 27 | "global/_utilities.scss", 28 | "libs/_library-variable-overrides.scss", 29 | "libs/_normalize.scss" 30 | ] 31 | } 32 | } -------------------------------------------------------------------------------- /web/static/css/app.sass: -------------------------------------------------------------------------------- 1 | // - - css-burrito v1.5 | mit license | github.com/jasonreece/css-burrito 2 | 3 | // - - - - - - - - - - - - - - - - - - - 4 | // - - application.scss 5 | // contains an import section for libs, global, and modules, the inbox, 6 | // and a shame section for quick fixes and hacks. 7 | 8 | // - - reset - normalize.css v3.0.2 9 | @import libs/normalize 10 | 11 | // - - - - - - - - - - - - - - - - - - - 12 | // - - libs 13 | // add css frameworks and libraries here. 14 | // be sure to load them after the variable overrides file 15 | // if you want to change variable values. 16 | @import bourbon/app/assets/stylesheets/bourbon 17 | @import bourbon-neat/app/assets/stylesheets/neat-helpers 18 | @import bitters/grid-settings 19 | @import bourbon-neat/app/assets/stylesheets/neat 20 | @import bitters/base 21 | 22 | // - - variable overrides for sass libraries 23 | // @import "libs/library-variable-overrides"; 24 | 25 | // - - - - - - - - - - - - - - - - - - - 26 | // - - global 27 | 28 | // - - global/settings 29 | // @font-face declarations, variables 30 | @import global/settings 31 | 32 | // - - global/utilities 33 | // extends, functions, and mixins 34 | @import global/utilities 35 | 36 | // - - global/base 37 | // base-level tags (body, p, etc.) 38 | @import global/base 39 | 40 | // - - global/layout 41 | // margin, padding, sizing 42 | @import global/layout 43 | 44 | // - - global/skin 45 | // backgrounds, borders, box-shadow, etc 46 | @import global/skin 47 | 48 | // - - global/typography 49 | // fonts and colors 50 | @import global/typography 51 | 52 | // - - - - - - - - - - - - - - - - - - - 53 | // - - modules 54 | // add new modules to the modules/_modules.scss file and they'll get pulled in here. 55 | @import modules/modules 56 | 57 | // - - - - - - - - - - - - - - - - - - - 58 | // - - inbox 59 | // the inbox allows developers, and those not actively working on the project 60 | // to quickly add styles that are easily seen by the maintainer of the file. 61 | 62 | // - - - - - - - - - - - - - - - - - - - 63 | // - - shame 64 | // need to add a quick fix, hack, or questionable technique? add it here, fix it later. 65 | -------------------------------------------------------------------------------- /web/static/css/bitters/_base.sass: -------------------------------------------------------------------------------- 1 | // Bitters 1.2.0 2 | // http://bitters.bourbon.io 3 | // Copyright 2013-2015 thoughtbot, inc. 4 | // MIT License 5 | 6 | @import variables 7 | 8 | // Neat Settings -- uncomment if using Neat -- must be imported before Neat 9 | // @import "grid-settings"; 10 | 11 | @import buttons 12 | @import forms 13 | @import lists 14 | @import tables 15 | @import typography 16 | -------------------------------------------------------------------------------- /web/static/css/bitters/_buttons.sass: -------------------------------------------------------------------------------- 1 | #{$all-buttons} 2 | appearance: none 3 | background-color: $action-color 4 | border: 0 5 | border-radius: $base-border-radius 6 | color: #fff 7 | cursor: pointer 8 | display: inline-block 9 | font-family: $base-font-family 10 | font-size: $base-font-size 11 | -webkit-font-smoothing: antialiased 12 | font-weight: 600 13 | line-height: 1 14 | padding: $small-spacing $base-spacing 15 | text-decoration: none 16 | transition: background-color $base-duration $base-timing 17 | user-select: none 18 | vertical-align: middle 19 | white-space: nowrap 20 | 21 | &:hover, 22 | &:focus 23 | background-color: shade($action-color, 20%) 24 | color: #fff 25 | outline: none 26 | 27 | &:disabled 28 | cursor: not-allowed 29 | opacity: 0.5 30 | 31 | &:hover 32 | background-color: $action-color 33 | -------------------------------------------------------------------------------- /web/static/css/bitters/_forms.sass: -------------------------------------------------------------------------------- 1 | fieldset 2 | background-color: transparent 3 | border: 0 4 | margin: 0 5 | padding: 0 6 | 7 | legend 8 | font-weight: 600 9 | margin-bottom: $small-spacing / 2 10 | padding: 0 11 | 12 | label 13 | display: block 14 | font-weight: 600 15 | margin-bottom: $small-spacing / 2 16 | 17 | input, 18 | select 19 | display: block 20 | font-family: $base-font-family 21 | font-size: $base-font-size 22 | 23 | #{$all-text-inputs}, 24 | select[multiple] 25 | background-color: $base-background-color 26 | border: $base-border 27 | border-radius: $base-border-radius 28 | box-shadow: $form-box-shadow 29 | box-sizing: border-box 30 | font-family: $base-font-family 31 | font-size: $base-font-size 32 | margin-bottom: $small-spacing 33 | padding: $base-spacing / 2 34 | transition: border-color $base-duration $base-timing 35 | width: 100% 36 | 37 | &:hover 38 | border-color: $base-border 39 | 40 | &:focus 41 | box-shadow: $form-box-shadow-focus 42 | outline: none 43 | 44 | &:disabled 45 | background-color: shade($base-background-color, 5%) 46 | cursor: not-allowed 47 | 48 | &:hover 49 | border: $base-border 50 | 51 | textarea 52 | resize: vertical 53 | 54 | [type="search"] 55 | appearance: none 56 | 57 | [type="checkbox"], 58 | [type="radio"] 59 | display: inline 60 | margin-right: $small-spacing / 2 61 | 62 | [type="file"] 63 | margin-bottom: $small-spacing 64 | width: 100% 65 | 66 | select 67 | margin-bottom: $base-spacing 68 | max-width: 100% 69 | width: auto 70 | 71 | .field 72 | margin-bottom: $small-spacing 73 | 74 | input 75 | margin-bottom: 0 76 | 77 | .error 78 | margin-top: $small-spacing 79 | color: $red 80 | text-align: left 81 | -------------------------------------------------------------------------------- /web/static/css/bitters/_grid-settings.sass: -------------------------------------------------------------------------------- 1 | // or "../neat/neat-helpers" when not in Rails 2 | 3 | // Neat Overrides 4 | // $column: 90px; 5 | // $gutter: 30px; 6 | // $grid-columns: 12; 7 | $max-width: 1152px 8 | 9 | // Neat Breakpoints 10 | $medium-screen: 600px 11 | $large-screen: 900px 12 | 13 | $medium-screen-up: new-breakpoint(min-width $medium-screen 4) 14 | $large-screen-up: new-breakpoint(min-width $large-screen 8) 15 | -------------------------------------------------------------------------------- /web/static/css/bitters/_lists.sass: -------------------------------------------------------------------------------- 1 | ul, 2 | ol 3 | list-style-type: none 4 | margin: 0 5 | padding: 0 6 | 7 | dl 8 | margin-bottom: $small-spacing 9 | 10 | dt 11 | font-weight: 600 12 | margin-top: $small-spacing 13 | 14 | dd 15 | margin: 0 16 | -------------------------------------------------------------------------------- /web/static/css/bitters/_tables.sass: -------------------------------------------------------------------------------- 1 | table 2 | border-collapse: collapse 3 | margin: $small-spacing 0 4 | table-layout: fixed 5 | width: 100% 6 | 7 | th 8 | border-bottom: 1px solid shade($base-border-color, 25%) 9 | font-weight: 600 10 | padding: $small-spacing 0 11 | text-align: left 12 | 13 | td 14 | border-bottom: $base-border 15 | padding: $small-spacing 0 16 | 17 | tr, 18 | td, 19 | th 20 | vertical-align: middle 21 | -------------------------------------------------------------------------------- /web/static/css/bitters/_typography.sass: -------------------------------------------------------------------------------- 1 | body 2 | color: $base-font-color 3 | font-family: $base-font-family 4 | font-size: 14px 5 | line-height: $base-line-height 6 | -webkit-font-smoothing: subpixel-antialiased 7 | 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6 14 | font-family: $heading-font-family 15 | font-size: $base-font-size 16 | line-height: $heading-line-height 17 | margin: 0 0 $small-spacing 18 | 19 | p 20 | margin: 0 0 $small-spacing 21 | 22 | a 23 | color: $action-color 24 | text-decoration: none 25 | transition: color $base-duration $base-timing 26 | 27 | &:active, 28 | &:focus, 29 | &:hover 30 | color: shade($action-color, 25%) 31 | 32 | hr 33 | border-bottom: $base-border 34 | border-left: 0 35 | border-right: 0 36 | border-top: 0 37 | margin: $base-spacing 0 38 | 39 | img, 40 | picture 41 | margin: 0 42 | max-width: 100% 43 | -------------------------------------------------------------------------------- /web/static/css/bitters/_variables.sass: -------------------------------------------------------------------------------- 1 | // Typography 2 | $base-font-family: 'Open Sans', sans-serif 3 | $heading-font-family: 'Ropa Sans', sans-serif 4 | 5 | // Font Sizes 6 | $base-font-size: 1em 7 | 8 | // Line height 9 | $base-line-height: 1.5 10 | $heading-line-height: 1.2 11 | 12 | // Other Sizes 13 | $base-border-radius: 0 14 | $base-spacing: $base-line-height * 1em 15 | $small-spacing: $base-spacing / 2 16 | $base-z-index: 0 17 | 18 | // Colors 19 | $blue: #0086e3 20 | $light-blue: tint($blue, 10) 21 | 22 | $red: #f30c16 23 | $dark-red: shade($red, 20) 24 | $light-gray: #f5f5f5 25 | $gray: shade($light-gray, 20) 26 | $dark-gray: shade($light-gray, 40) 27 | $medium-gray: shade($light-gray, 20) 28 | 29 | $green: #4bc800 30 | $dark-green: shade($green, 20) 31 | 32 | // Font Colors 33 | $base-font-color: shade($dark-gray, 20) 34 | $action-color: $red 35 | 36 | // Border 37 | $base-border-color: $light-gray 38 | $base-border: 1px solid $base-border-color 39 | 40 | // Background Colors 41 | $base-background-color: #fff 42 | $secondary-background-color: tint($base-border-color, 75%) 43 | 44 | // Forms 45 | $form-box-shadow: 0 1px 3px rgba(0,0,0,.20) 46 | $form-box-shadow-focus: $form-box-shadow 47 | 48 | // Animations 49 | $base-duration: 150ms 50 | $base-timing: ease 51 | 52 | 53 | @keyframes jelly 54 | 0% 55 | transform: matrix3d(0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 56 | 57 | 3.333333% 58 | transform: matrix3d(0.87258, 0, 0, 0, 0, 0.68602, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 59 | 60 | 6.666667% 61 | transform: matrix3d(1.00758, 0, 0, 0, 0, 0.90691, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 62 | 63 | 10% 64 | transform: matrix3d(1.02235, 0, 0, 0, 0, 1.07226, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 65 | 66 | 13.333333% 67 | transform: matrix3d(1.01029, 0, 0, 0, 0, 1.14684, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 68 | 69 | 16.666667% 70 | transform: matrix3d(1.002, 0, 0, 0, 0, 1.14088, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 71 | 72 | 20% 73 | transform: matrix3d(0.99953, 0, 0, 0, 0, 1.08847, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 74 | 75 | 23.333333% 76 | transform: matrix3d(0.99947, 0, 0, 0, 0, 1.02623, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 77 | 78 | 26.666667% 79 | transform: matrix3d(0.9998, 0, 0, 0, 0, 0.97964, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 80 | 81 | 30% 82 | transform: matrix3d(0.99997, 0, 0, 0, 0, 0.95863, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 83 | 84 | 33.333333% 85 | transform: matrix3d(1.00002, 0, 0, 0, 0, 0.9603, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 86 | 87 | 36.666667% 88 | transform: matrix3d(1.00001, 0, 0, 0, 0, 0.97507, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 89 | 90 | 40% 91 | transform: matrix3d(1, 0, 0, 0, 0, 0.99261, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 92 | 93 | 43.333333% 94 | transform: matrix3d(1, 0, 0, 0, 0, 1.00574, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 95 | 96 | 46.666667% 97 | transform: matrix3d(1, 0, 0, 0, 0, 1.01166, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 98 | 99 | 50% 100 | transform: matrix3d(1, 0, 0, 0, 0, 1.01119, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 101 | 102 | 53.333333% 103 | transform: matrix3d(1, 0, 0, 0, 0, 1.00702, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 104 | 105 | 56.666667% 106 | transform: matrix3d(1, 0, 0, 0, 0, 1.00208, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 107 | 108 | 60% 109 | transform: matrix3d(1, 0, 0, 0, 0, 0.99838, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 110 | 111 | 63.333333% 112 | transform: matrix3d(1, 0, 0, 0, 0, 0.99672, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 113 | 114 | 66.666667% 115 | transform: matrix3d(1, 0, 0, 0, 0, 0.99685, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 116 | 117 | 70% 118 | transform: matrix3d(1, 0, 0, 0, 0, 0.99802, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 119 | 120 | 73.333333% 121 | transform: matrix3d(1, 0, 0, 0, 0, 0.99941, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 122 | 123 | 76.666667% 124 | transform: matrix3d(1, 0, 0, 0, 0, 1.00046, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 125 | 126 | 80% 127 | transform: matrix3d(1, 0, 0, 0, 0, 1.00093, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 128 | 129 | 83.333333% 130 | transform: matrix3d(1, 0, 0, 0, 0, 1.00089, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 131 | 132 | 86.666667% 133 | transform: matrix3d(1, 0, 0, 0, 0, 1.00056, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 134 | 135 | 90% 136 | transform: matrix3d(1, 0, 0, 0, 0, 1.00017, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 137 | 138 | 93.333333% 139 | transform: matrix3d(1, 0, 0, 0, 0, 0.99987, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 140 | 141 | 96.666667% 142 | transform: matrix3d(1, 0, 0, 0, 0, 0.99974, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 143 | 144 | 100% 145 | transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) 146 | -------------------------------------------------------------------------------- /web/static/css/global/_base.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - base 3 | // project defaults for base elements - h1-h6, p, a, etc. 4 | -------------------------------------------------------------------------------- /web/static/css/global/_layout.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - layout 3 | // global layout classes - height, width, padding, margin, etc. 4 | 5 | html, body 6 | min-height: 100% 7 | 8 | 9 | #main_wrapper 10 | min-height: 100vh 11 | 12 | .main-container 13 | min-height: calc(100vh - 170px) 14 | 15 | .container 16 | +outer-container 17 | -------------------------------------------------------------------------------- /web/static/css/global/_settings.sass: -------------------------------------------------------------------------------- 1 | $base-padding: 1em 2 | -------------------------------------------------------------------------------- /web/static/css/global/_skin.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - skin 3 | // global skin styles - gradients, colors, box-shadows, etc. 4 | 5 | body 6 | background-color: $light-gray 7 | -------------------------------------------------------------------------------- /web/static/css/global/_typography.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - typography 3 | // global typography styles 4 | -------------------------------------------------------------------------------- /web/static/css/global/_utilities.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - utilities 3 | // placeholders extends, mixins, functions, and utility classes 4 | 5 | // - - - - - - - - - - - - - - - - - - - 6 | // - - placeholder extends 7 | 8 | // - - - - - - - - - - - - - - - - - - - 9 | // - - mixins 10 | 11 | // - - clearfix 12 | // @mixin clearfix { 13 | // &:before, 14 | // &:after { 15 | // content: ' '; 16 | // display: table; 17 | // } 18 | // &:after { 19 | // clear: both; 20 | // } 21 | // & { 22 | // *zoom: 1; 23 | // } 24 | // } 25 | 26 | // - - breakpoint 27 | // adds responsive breakpoints. 28 | // @mixin breakpoint($width) { 29 | // @media (min-width: $width) { 30 | // @content; 31 | // } 32 | // } 33 | 34 | // - - attention 35 | // adds accessibility pseudo selectors to hover states. 36 | // @mixin attention() { 37 | // &:hover, 38 | // &:active, 39 | // &:focus { 40 | // @content; 41 | // } 42 | // } 43 | 44 | // - - - - - - - - - - - - - - - - - - - 45 | // - - functions 46 | 47 | // - - - - - - - - - - - - - - - - - - - 48 | // - - utilities 49 | -------------------------------------------------------------------------------- /web/static/css/libs/_library-variable-overrides.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - library variable overrides 3 | // variable overrides for css frameworks and libraries 4 | -------------------------------------------------------------------------------- /web/static/css/libs/_normalize.sass: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | 8 | html 9 | font-family: sans-serif 10 | 11 | /* 1 12 | -ms-text-size-adjust: 100% 13 | 14 | /* 2 15 | -webkit-text-size-adjust: 100% 16 | 17 | /* 2 18 | 19 | /** 20 | * Remove default margin. 21 | 22 | body 23 | margin: 0 24 | 25 | /* HTML5 display definitions 26 | * ========================================================================== 27 | 28 | /** 29 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 30 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 31 | * and Firefox. 32 | * Correct `block` display not defined for `main` in IE 11. 33 | 34 | article, 35 | aside, 36 | details, 37 | figcaption, 38 | figure, 39 | footer, 40 | header, 41 | hgroup, 42 | main, 43 | menu, 44 | nav, 45 | section, 46 | summary 47 | display: block 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | 53 | audio, 54 | canvas, 55 | progress, 56 | video 57 | display: inline-block 58 | 59 | /* 1 60 | vertical-align: baseline 61 | 62 | /* 2 63 | 64 | /** 65 | * Prevent modern browsers from displaying `audio` without controls. 66 | * Remove excess height in iOS 5 devices. 67 | 68 | audio:not([controls]) 69 | display: none 70 | height: 0 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | 76 | [hidden], 77 | template 78 | display: none 79 | 80 | /* Links 81 | * ========================================================================== 82 | 83 | /** 84 | * Remove the gray background color from active links in IE 10. 85 | 86 | a 87 | background-color: transparent 88 | 89 | /** 90 | * Improve readability when focused and also mouse hovered in all browsers. 91 | 92 | a:active, 93 | a:hover 94 | outline: 0 95 | 96 | /* Text-level semantics 97 | * ========================================================================== 98 | 99 | /** 100 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 101 | 102 | abbr[title] 103 | border-bottom: 1px dotted 104 | 105 | /** 106 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 107 | 108 | b, 109 | strong 110 | font-weight: bold 111 | 112 | /** 113 | * Address styling not present in Safari and Chrome. 114 | 115 | dfn 116 | font-style: italic 117 | 118 | /** 119 | * Address variable `h1` font-size and margin within `section` and `article` 120 | * contexts in Firefox 4+, Safari, and Chrome. 121 | 122 | h1 123 | font-size: 2em 124 | margin: 0.67em 0 125 | 126 | /** 127 | * Address styling not present in IE 8/9. 128 | 129 | mark 130 | background: #ff0 131 | color: #000 132 | 133 | /** 134 | * Address inconsistent and variable font size in all browsers. 135 | 136 | small 137 | font-size: 80% 138 | 139 | /** 140 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 141 | 142 | sub, 143 | sup 144 | font-size: 75% 145 | line-height: 0 146 | position: relative 147 | vertical-align: baseline 148 | 149 | sup 150 | top: -0.5em 151 | 152 | sub 153 | bottom: -0.25em 154 | 155 | /* Embedded content 156 | * ========================================================================== 157 | 158 | /** 159 | * Remove border when inside `a` element in IE 8/9/10. 160 | 161 | img 162 | border: 0 163 | 164 | /** 165 | * Correct overflow not hidden in IE 9/10/11. 166 | 167 | svg:not(:root) 168 | overflow: hidden 169 | 170 | /* Grouping content 171 | * ========================================================================== 172 | 173 | /** 174 | * Address margin not present in IE 8/9 and Safari. 175 | 176 | figure 177 | margin: 1em 40px 178 | 179 | /** 180 | * Address differences between Firefox and other browsers. 181 | 182 | hr 183 | -moz-box-sizing: content-box 184 | box-sizing: content-box 185 | height: 0 186 | 187 | /** 188 | * Contain overflow in all browsers. 189 | 190 | pre 191 | overflow: auto 192 | 193 | /** 194 | * Address odd `em`-unit font size rendering in all browsers. 195 | 196 | code, 197 | kbd, 198 | pre, 199 | samp 200 | font-family: monospace, monospace 201 | font-size: 1em 202 | 203 | /* Forms 204 | * ========================================================================== 205 | 206 | /** 207 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 208 | * styling of `select`, unless a `border` property is set. 209 | 210 | /** 211 | * 1. Correct color not being inherited. 212 | * Known issue: affects color of disabled elements. 213 | * 2. Correct font properties not being inherited. 214 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 215 | 216 | button, 217 | input, 218 | optgroup, 219 | select, 220 | textarea 221 | color: inherit 222 | 223 | /* 1 224 | font: inherit 225 | 226 | /* 2 227 | margin: 0 228 | 229 | /* 3 230 | 231 | /** 232 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 233 | 234 | button 235 | overflow: visible 236 | 237 | /** 238 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 239 | * All other form control elements do not inherit `text-transform` values. 240 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 241 | * Correct `select` style inheritance in Firefox. 242 | 243 | button, 244 | select 245 | text-transform: none 246 | 247 | /** 248 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 249 | * and `video` controls. 250 | * 2. Correct inability to style clickable `input` types in iOS. 251 | * 3. Improve usability and consistency of cursor style between image-type 252 | * `input` and others. 253 | 254 | button, 255 | html input[type="button"], 256 | input[type="reset"], 257 | input[type="submit"] 258 | -webkit-appearance: button 259 | 260 | /* 2 261 | cursor: pointer 262 | 263 | /* 3 264 | 265 | /** 266 | * Re-set default cursor for disabled elements. 267 | 268 | button[disabled], 269 | html input[disabled] 270 | cursor: default 271 | 272 | /** 273 | * Remove inner padding and border in Firefox 4+. 274 | 275 | button::-moz-focus-inner, 276 | input::-moz-focus-inner 277 | border: 0 278 | padding: 0 279 | 280 | /** 281 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 282 | * the UA stylesheet. 283 | 284 | input 285 | line-height: normal 286 | 287 | /** 288 | * It's recommended that you don't attempt to style these elements. 289 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 290 | * 291 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 292 | * 2. Remove excess padding in IE 8/9/10. 293 | 294 | input[type="checkbox"], 295 | input[type="radio"] 296 | box-sizing: border-box 297 | 298 | /* 1 299 | padding: 0 300 | 301 | /* 2 302 | 303 | /** 304 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 305 | * `font-size` values of the `input`, it causes the cursor style of the 306 | * decrement button to change from `default` to `text`. 307 | 308 | input[type="number"]::-webkit-inner-spin-button, 309 | input[type="number"]::-webkit-outer-spin-button 310 | height: auto 311 | 312 | /** 313 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 314 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 315 | * (include `-moz` to future-proof). 316 | 317 | input[type="search"] 318 | -webkit-appearance: textfield 319 | 320 | /* 1 321 | -moz-box-sizing: content-box 322 | -webkit-box-sizing: content-box 323 | 324 | /* 2 325 | box-sizing: content-box 326 | 327 | /** 328 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 329 | * Safari (but not Chrome) clips the cancel button when the search input has 330 | * padding (and `textfield` appearance). 331 | 332 | input[type="search"]::-webkit-search-cancel-button, 333 | input[type="search"]::-webkit-search-decoration 334 | -webkit-appearance: none 335 | 336 | /** 337 | * Define consistent border, margin, and padding. 338 | 339 | fieldset 340 | border: 1px solid #c0c0c0 341 | margin: 0 2px 342 | padding: 0.35em 0.625em 0.75em 343 | 344 | /** 345 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 346 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 347 | 348 | legend 349 | border: 0 350 | 351 | /* 1 352 | padding: 0 353 | 354 | /* 2 355 | 356 | /** 357 | * Remove default vertical scrollbar in IE 8/9/10/11. 358 | 359 | textarea 360 | overflow: auto 361 | 362 | /** 363 | * Don't inherit the `font-weight` (applied by a rule above). 364 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 365 | 366 | optgroup 367 | font-weight: bold 368 | 369 | /* Tables 370 | * ========================================================================== 371 | 372 | /** 373 | * Remove most spacing between table cells. 374 | 375 | table 376 | border-collapse: collapse 377 | border-spacing: 0 378 | 379 | td, 380 | th 381 | padding: 0 382 | -------------------------------------------------------------------------------- /web/static/css/modules/_example-module.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - example-module module 3 | // styles for the example-module module 4 | -------------------------------------------------------------------------------- /web/static/css/modules/_main_footer.sass: -------------------------------------------------------------------------------- 1 | #main_footer 2 | color: $dark-gray 3 | border-top: 1px solid shade($light-gray, 20) 4 | padding: $base-spacing*2 0 5 | background: shade($light-gray, 10) 6 | box-shadow: inset 0 0 6px rgba(0,0,0,.1) 7 | 8 | p 9 | margin-bottom: 0 10 | 11 | .container 12 | font-size: .85em 13 | text-align: center 14 | 15 | strong 16 | color: $red 17 | -------------------------------------------------------------------------------- /web/static/css/modules/_main_header.sass: -------------------------------------------------------------------------------- 1 | #main_header 2 | background: #333 3 | margin-bottom: $base-spacing 4 | 5 | nav 6 | display: inline-block 7 | float: left 8 | > ul > li > a 9 | border-right: 1px solid rgba(#fff, .1) 10 | &:last-of-type 11 | border-left: 1px solid rgba(#fff, .1) 12 | 13 | .logo-link 14 | font-size: 1.3em 15 | font-weight: bold 16 | margin-right: $base-spacing 17 | 18 | .logo 19 | $size: 26px 20 | width: $size 21 | height: $size 22 | border-radius: 50% 23 | background-color: $red 24 | display: inline-block 25 | vertical-align: middle 26 | 27 | a 28 | color: $light-gray 29 | 30 | &:hover 31 | color: #fff 32 | 33 | &.active 34 | background: $light-gray 35 | color: #333 36 | 37 | .right 38 | float: right 39 | 40 | ul 41 | display: inline-block 42 | li 43 | display: inline-block 44 | a 45 | line-height: 3.5em 46 | display: inline-block 47 | padding: 0 2em 48 | 49 | &.menu-wrapper 50 | position: relative 51 | 52 | > a 53 | +transition 54 | position: relative 55 | 56 | &:hover, &.visible 57 | background: #000 58 | 59 | &:after 60 | content: "" 61 | width: 0 62 | height: 0 63 | border: solid 6px 64 | border-color: #595959 transparent transparent 65 | margin-right: 30px 66 | margin-top: -3px 67 | display: inline-block 68 | position: static 69 | right: auto 70 | margin: 0 0 -6px 12px 71 | line-height: 45px 72 | 73 | .dropdown 74 | position: absolute 75 | display: block 76 | z-index: 99 77 | background: #000 78 | right: 0 79 | width: 12em 80 | animation-duration: .3s 81 | animation-name: fadeIn 82 | padding: .5em 0 83 | 84 | li 85 | display: block 86 | 87 | a 88 | +transition 89 | padding: 0 1em 90 | display: block 91 | 92 | &:hover 93 | background: tint(#333, 10) 94 | -------------------------------------------------------------------------------- /web/static/css/modules/_modules.sass: -------------------------------------------------------------------------------- 1 | // - - - - - - - - - - - - - - - - - - - 2 | // - - modules 3 | // add new modules here 4 | 5 | // @import "example-module"; 6 | 7 | @import main_header 8 | @import main_footer 9 | 10 | @import auth/new 11 | @import home/index 12 | @import reports/index 13 | -------------------------------------------------------------------------------- /web/static/css/modules/auth/_new.sass: -------------------------------------------------------------------------------- 1 | #sessions_new_view, #registrations_new_view 2 | min-height: 100vh 3 | background: rgba(43,37,37,.95) 4 | position: relative 5 | 6 | > .form-wrapper 7 | +transform(translate(-50%, -50%)) 8 | position: absolute 9 | font-size: 1.1em 10 | width: 20em 11 | top: 50% 12 | left: 50% 13 | text-align: center 14 | 15 | .inner 16 | animation: jelly 1s linear both 17 | background: $light-gray 18 | padding: $base-padding * 1.5 19 | margin-bottom: $base-spacing * 1.5 20 | 21 | header 22 | margin-bottom: $base-spacing * 1.5 23 | p 24 | line-height: $base-line-height 25 | font-size: .9em 26 | a 27 | +transition 28 | margin: 0 29 | color: $red 30 | &:hover 31 | color: $dark-red 32 | strong 33 | color: $red 34 | 35 | .logo 36 | $size: 60px 37 | width: $size 38 | height: $size 39 | border-radius: 50% 40 | background-color: $red 41 | margin: 0 auto 42 | margin-bottom: $base-spacing 43 | 44 | 45 | button, a 46 | font-family: $heading-font-family 47 | letter-spacing: .08em 48 | text-transform: uppercase 49 | font-size: .9em 50 | margin-top: $base-spacing 51 | 52 | button 53 | width: 50% 54 | 55 | a 56 | display: inline-block 57 | color: #fff 58 | -------------------------------------------------------------------------------- /web/static/css/modules/home/_index.sass: -------------------------------------------------------------------------------- 1 | #home_index 2 | .timer-container 3 | position: relative 4 | padding-top: $base-spacing 5 | 6 | .timer-actions 7 | position: absolute 8 | top: 0 9 | right: 0 10 | z-index: 9 11 | line-height: 1em 12 | 13 | a 14 | color: $blue 15 | font-weight: bold 16 | font-size: .8em 17 | 18 | &:hover 19 | color: $light-blue 20 | 21 | .timer-wrapper 22 | +display(flex) 23 | background-color: #fff 24 | position: relative 25 | box-shadow: 0 1px 4px rgba(128,128,128,.4) 26 | font-size: 1.4em 27 | margin-bottom: $base-spacing*2 28 | 29 | > div 30 | +display(flex) 31 | +flex(0 0 auto) 32 | 33 | .description-container 34 | +box-flex(1) 35 | +flex(1 1 auto) 36 | 37 | .date-time-container 38 | padding: 0 $base-spacing 39 | input 40 | color: $base-font-color 41 | width: 7em 42 | 43 | .button-container 44 | width: 20% 45 | max-width: 192px 46 | 47 | button 48 | +fill-parent 49 | color: #fff 50 | 51 | &.started 52 | background: $green 53 | 54 | &:hover 55 | background: $dark-green 56 | 57 | [type=text] 58 | margin: 0 59 | box-shadow: 0 0 0 60 | border: 0px none 61 | 62 | .time-entries 63 | margin-bottom: $base-spacing*1.5 64 | 65 | header 66 | +display(flex) 67 | margin-bottom: $base-spacing/2 68 | > div 69 | line-height: 51px 70 | .title 71 | font-size: 1.3em 72 | margin-right: .5em 73 | ul 74 | li 75 | +display(flex) 76 | +transition 77 | height: 52px 78 | border-bottom: 1px solid shade($light-gray, 10) 79 | &:hover 80 | background: shade($light-gray, 5) 81 | 82 | .continue-container 83 | a 84 | opacity: 1 85 | > div 86 | +flex(0 0 auto) 87 | line-height: 51px 88 | 89 | .checkbox-container 90 | +transition 91 | padding: 0 0 0 $base-spacing 92 | text-align: center 93 | position: relative 94 | cursor: pointer 95 | border: 1px solid transparent 96 | margin-right: $small-spacing 97 | 98 | &:hover, &.active 99 | border: 1px solid $gray 100 | .fa-caret-down 101 | opacity: 1 102 | 103 | 104 | .fa-caret-down 105 | +transition 106 | display: inline-block 107 | opacity: 0 108 | font-size: 1.2em 109 | padding: 0 $small-spacing 110 | line-height: 52px 111 | 112 | 113 | [type=checkbox] 114 | display: none 115 | + label 116 | margin-bottom: 0 117 | display: inline-block 118 | &:before 119 | content: "" 120 | display: inline-block 121 | height: 15px 122 | width: 15px 123 | text-align: center 124 | color: #fff 125 | border: solid 1px #ddd 126 | background: #fff 127 | position: relative 128 | line-height: 15px 129 | top: 2px 130 | vertical-align: baseline 131 | cursor: pointer 132 | &:checked + label:before 133 | content: "\f00c" 134 | font-family: FontAwesome 135 | color: #333 136 | 137 | .dropdown 138 | position: absolute 139 | display: block 140 | z-index: 99 141 | left: 0 142 | animation-duration: .3s 143 | animation-name: fadeIn 144 | background: #fff 145 | border: 1px solid $gray 146 | width: 10em 147 | padding: 4px 0 148 | text-align: left 149 | box-shadow: 0 1px 3px rgba($gray,.5) 150 | margin-top: -3fpx 151 | margin-left: -1px 152 | 153 | li 154 | display: block 155 | height: auto 156 | border-bottom: 0px none 157 | 158 | a 159 | display: block 160 | color: $dark-gray 161 | line-height: 2em 162 | padding: 0 1em 163 | &:hover 164 | color: $base-font-color 165 | 166 | 167 | 168 | .description-container 169 | +flex(1 1 auto) 170 | cursor: pointer 171 | 172 | 173 | [type=text] 174 | margin-bottom: 0 175 | display: inline-block 176 | width: 25em 177 | 178 | .continue-container 179 | a 180 | +transition 181 | color: shade($light-gray, 20) 182 | opacity: 0 183 | font-size: 1.2em 184 | &:hover 185 | color: $green 186 | 187 | .duration-container 188 | width: 150px 189 | text-align: right 190 | padding-right: $base-spacing 191 | -------------------------------------------------------------------------------- /web/static/css/modules/reports/_index.sass: -------------------------------------------------------------------------------- 1 | #reports_index 2 | .view-header 3 | +clearfix 4 | margin: $base-spacing 0 5 | h1 6 | +span-columns(8) 7 | font-family: $base-font-family 8 | font-size: 2em 9 | font-weight: 400 10 | line-height: 4rem 11 | margin-bottom: 0 12 | color: #333 13 | 14 | .range-selector 15 | +span-columns(4) 16 | text-align: right 17 | 18 | h2 19 | font-family: $base-font-family 20 | font-size: 1.6em 21 | font-weight: 400 22 | margin-bottom: 0 23 | line-height: 4rem 24 | color: #333 25 | display: inline-block 26 | margin-right: .3em 27 | position: relative 28 | cursor: pointer 29 | 30 | &:after 31 | content: "" 32 | width: 0 33 | height: 0 34 | border: solid 6px 35 | border-color: $gray transparent transparent 36 | display: inline-block 37 | position: static 38 | right: auto 39 | margin: 0 0 -3px 12px 40 | line-height: 45px 41 | 42 | > ul 43 | > li 44 | position: relative 45 | 46 | .dropdown 47 | position: absolute 48 | display: block 49 | z-index: 99 50 | right: 0 51 | animation-duration: .3s 52 | animation-name: fadeIn 53 | padding: 1em 54 | background: tint($gray, 20) 55 | 56 | li 57 | display: inline-block 58 | 59 | &:not(:last-of-type) 60 | margin-right: 1em 61 | 62 | a 63 | color: $dark-gray 64 | &:hover 65 | color: $base-font-color 66 | 67 | 68 | .chart-container 69 | header 70 | margin-bottom: $base-spacing 71 | span 72 | display: inline-block 73 | vertical-align: middle 74 | .total 75 | color: #333 76 | font-size: 1.6em 77 | font-weight: bold 78 | margin-left: 1em 79 | 80 | .js-chart-container 81 | height: 324px 82 | 83 | .barchart-container 84 | height: 100% 85 | border-bottom: 1px solid $gray 86 | padding-bottom: 66px 87 | 88 | .barchart-chart 89 | box-shadow: 0 1px 3px rgba($gray,.2) 90 | position: relative 91 | height: 100% 92 | padding-top: 54px 93 | background: #fff 94 | 95 | &.hide-labels 96 | li:nth-child(even) 97 | .label, .value 98 | opacity: 0 99 | 100 | .bar 101 | .label, .value 102 | +transition 103 | li:hover 104 | .bar 105 | .label, .value 106 | opacity: 1 107 | 108 | &:hover 109 | .label, .value 110 | opacity: 0 111 | 112 | .barchart-grid 113 | overflow: hidden 114 | position: absolute 115 | bottom: 0 116 | width: 100% 117 | height: 100% 118 | padding: 54px 60px 0 119 | border-bottom: 4px solid #ddd 120 | 121 | li 122 | color: #ddd 123 | font-weight: 600 124 | font-size: 12px 125 | position: relative 126 | border-top: 1px dotted #e6e6e6 127 | height: 20% 128 | 129 | span 130 | text-align: center 131 | position: absolute 132 | top: -6px 133 | right: -60px 134 | width: 60px 135 | font-weight: 400 136 | 137 | .barchart-cols 138 | +display(flex) 139 | position: absolute 140 | top: 54px 141 | left: 0 142 | right: 0 143 | bottom: 0 144 | padding: 0 60px 145 | text-align: center 146 | 147 | li 148 | +flex(1) 149 | margin: 0 150 | display: inline-block 151 | text-align: center 152 | position: relative 153 | min-width: 12px 154 | height: 100% 155 | 156 | .bar 157 | +transition(height .3s ease-out) 158 | color: #2cc1e6 159 | font-weight: 600 160 | position: absolute 161 | bottom: 0 162 | background: #2cc1e6 163 | min-height: 2% 164 | width: 75% 165 | 166 | &.zero-col 167 | height: 2% 168 | background: $gray 169 | color: $gray 170 | 171 | .label 172 | color: #929292 173 | font-weight: 600 174 | font-size: 12px 175 | position: absolute 176 | left: 50% 177 | width: 72px 178 | margin-left: -36px 179 | margin-bottom: 4px 180 | bottom: -67px 181 | height: 57px 182 | line-height: 19px 183 | white-space: nowrap 184 | 185 | .value 186 | font-size: 15px 187 | position: absolute 188 | top: -42px 189 | left: 50% 190 | width: 48px 191 | margin-left: -24px 192 | padding: 12px 0 193 | white-space: nowrap 194 | -------------------------------------------------------------------------------- /web/static/js/actions/header.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | export function showDropdown(show) { 4 | return dispatch => { 5 | dispatch({ 6 | type: Constants.HEADER_SHOW_DROPDOWN, 7 | show: show, 8 | }); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /web/static/js/actions/registrations.js: -------------------------------------------------------------------------------- 1 | import { push } from 'react-router-redux'; 2 | import Constants from '../constants'; 3 | import { httpPost } from '../utils'; 4 | import {setCurrentUser} from './sessions'; 5 | 6 | const Actions = {}; 7 | 8 | Actions.signUp = (data) => { 9 | return dispatch => { 10 | httpPost('/api/v1/registrations', { user: data }) 11 | .then((data) => { 12 | localStorage.setItem('phoenixAuthToken', data.jwt); 13 | 14 | setCurrentUser(dispatch, data.user); 15 | 16 | dispatch(push('/')); 17 | }) 18 | .catch((error) => { 19 | error.response.json() 20 | .then((errorJSON) => { 21 | dispatch({ 22 | type: Constants.REGISTRATIONS_ERROR, 23 | errors: errorJSON.errors, 24 | }); 25 | }); 26 | }); 27 | }; 28 | }; 29 | 30 | export default Actions; 31 | -------------------------------------------------------------------------------- /web/static/js/actions/reports.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | export function fetchData(channel, numberOfWeeks) { 4 | return dispatch => { 5 | dispatch({ 6 | type: Constants.REPORTS_DATA_FECTH_START, 7 | }); 8 | 9 | channel.push('reports:generate', { number_of_weeks: numberOfWeeks }) 10 | .receive('ok', (data) => { 11 | dispatch({ 12 | type: Constants.REPORTS_DATA_FECTH_SUCCESS, 13 | data: data, 14 | numberOfWeeks: numberOfWeeks, 15 | }); 16 | });; 17 | }; 18 | } 19 | 20 | export function showRangeSelector(show) { 21 | return dispatch => { 22 | dispatch({ 23 | type: Constants.REPORTS_SHOW_RANGE_SELECTOR, 24 | show: show, 25 | }); 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /web/static/js/actions/sessions.js: -------------------------------------------------------------------------------- 1 | import { push } from 'react-router-redux'; 2 | import Constants from '../constants'; 3 | import { Socket } from 'phoenix'; 4 | import { httpGet, httpPost, httpDelete } from '../utils'; 5 | 6 | export function setCurrentUser(dispatch, user) { 7 | const socket = new Socket('/socket', { 8 | params: { token: localStorage.getItem('phoenixAuthToken') }, 9 | logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data); }, 10 | }); 11 | 12 | socket.connect(); 13 | 14 | const channel = socket.channel(`users:${user.id}`); 15 | 16 | if (channel.state != 'joined') { 17 | channel.join().receive('ok', (payload) => { 18 | dispatch({ 19 | type: Constants.CURRENT_USER, 20 | currentUser: user, 21 | socket: socket, 22 | channel: channel, 23 | }); 24 | 25 | dispatch({ 26 | type: Constants.TIMER_SET_TIME_ENTRY, 27 | timeEntry: payload.time_entry, 28 | }); 29 | }); 30 | } 31 | }; 32 | 33 | const Actions = { 34 | signIn: (email, password) => { 35 | return dispatch => { 36 | const data = { 37 | session: { 38 | email: email, 39 | password: password, 40 | }, 41 | }; 42 | 43 | httpPost('/api/v1/sessions', data) 44 | .then((data) => { 45 | localStorage.setItem('phoenixAuthToken', data.jwt); 46 | setCurrentUser(dispatch, data.user); 47 | dispatch(push('/')); 48 | }) 49 | .catch((error) => { 50 | console.log(error); 51 | 52 | error.response.json() 53 | .then((errorJSON) => { 54 | dispatch({ 55 | type: Constants.SESSIONS_ERROR, 56 | error: errorJSON.error, 57 | }); 58 | }); 59 | }); 60 | }; 61 | }, 62 | 63 | currentUser: () => { 64 | return dispatch => { 65 | const authToken = localStorage.getItem('phoenixAuthToken'); 66 | 67 | httpGet('/api/v1/current_user') 68 | .then(function (data) { 69 | setCurrentUser(dispatch, data); 70 | }) 71 | .catch(function (error) { 72 | debugger; 73 | console.log(error); 74 | dispatch(push('/sign_in')); 75 | }); 76 | }; 77 | }, 78 | 79 | signOut: (socket, channel) => { 80 | return dispatch => { 81 | httpDelete('/api/v1/sessions') 82 | .then((data) => { 83 | localStorage.removeItem('phoenixAuthToken'); 84 | 85 | channel.leave(); 86 | socket.disconnect(); 87 | 88 | dispatch({ type: Constants.USER_SIGNED_OUT, }); 89 | 90 | dispatch(push('/sign_in')); 91 | }) 92 | .catch(function (error) { 93 | console.log(error); 94 | }); 95 | }; 96 | }, 97 | }; 98 | 99 | export default Actions; 100 | -------------------------------------------------------------------------------- /web/static/js/actions/time_entries.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | import { httpGet } from '../utils'; 3 | 4 | export function fetchTimeEntries() { 5 | return dispatch => { 6 | dispatch({ type: Constants.TIME_ENTRIES_FETCH_START }); 7 | 8 | httpGet('/api/v1/time_entries') 9 | .then((payload) => { 10 | dispatch({ 11 | type: Constants.TIME_ENTRIES_FETCH_SUCCESS, 12 | items: payload.time_entries, 13 | }); 14 | }); 15 | }; 16 | } 17 | 18 | export function appendTimeEntry(item) { 19 | return dispatch => { 20 | dispatch({ 21 | type: Constants.TIME_ENTRIES_APPEND_ITEM, 22 | item: item, 23 | }); 24 | }; 25 | } 26 | 27 | export function continueTimeEntry(item) { 28 | return dispatch => { 29 | dispatch({ 30 | type: Constants.TIME_ENTRIES_REMOVE_ITEM, 31 | item: item, 32 | }); 33 | 34 | dispatch({ 35 | type: Constants.TIMER_SET_TIME_ENTRY, 36 | timeEntry: item, 37 | }); 38 | }; 39 | } 40 | 41 | export function startTimer(item) { 42 | return dispatch => { 43 | dispatch({ 44 | type: Constants.TIMER_SET_TIME_ENTRY, 45 | timeEntry: item, 46 | }); 47 | }; 48 | } 49 | 50 | export function displayDropdown(id) { 51 | return dispatch => { 52 | dispatch({ 53 | type: Constants.TIME_ENTRIES_DISPLAY_DROPDOWN_FOR, 54 | id: id, 55 | }); 56 | }; 57 | } 58 | 59 | export function removeTimeEntry(section, item) { 60 | return dispatch => { 61 | dispatch({ 62 | type: Constants.TIME_ENTRIES_REMOVE_ITEM, 63 | section: section, 64 | item: item, 65 | }); 66 | }; 67 | } 68 | 69 | export function removeTimeEntries(section, ids) { 70 | return dispatch => { 71 | dispatch({ 72 | type: Constants.TIME_ENTRIES_REMOVE_ITEMS, 73 | section: section, 74 | ids: ids, 75 | }); 76 | }; 77 | } 78 | 79 | export function selectTimeEntry(section, id) { 80 | return dispatch => { 81 | dispatch({ 82 | type: Constants.TIME_ENTRIES_SELECT_ITEM, 83 | section: section, 84 | id: id, 85 | }); 86 | }; 87 | } 88 | 89 | export function deselectTimeEntry(section, id) { 90 | return dispatch => { 91 | dispatch({ 92 | type: Constants.TIME_ENTRIES_DESELECT_ITEM, 93 | section: section, 94 | id: id, 95 | }); 96 | }; 97 | } 98 | 99 | export function selectSection(section, ids) { 100 | return dispatch => { 101 | dispatch({ 102 | type: Constants.TIME_ENTRIES_SELECT_SECTION, 103 | section: section, 104 | ids: ids, 105 | }); 106 | }; 107 | } 108 | 109 | export function deselectSection(section, ids) { 110 | return dispatch => { 111 | dispatch({ 112 | type: Constants.TIME_ENTRIES_DESELECT_SECTION, 113 | section: section, 114 | ids: ids, 115 | }); 116 | }; 117 | } 118 | 119 | export function editItem(id) { 120 | return dispatch => { 121 | dispatch({ 122 | type: Constants.TIME_ENTRIES_EDIT_ITEM, 123 | id: id, 124 | }); 125 | }; 126 | } 127 | 128 | export function replaceTimeEntry(item) { 129 | return dispatch => { 130 | dispatch({ 131 | type: Constants.TIME_ENTRIES_REPLACE_ITEM, 132 | item: item, 133 | }); 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /web/static/js/actions/timer.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | export default { 4 | start: (timeEntry) => { 5 | return dispatch => { 6 | dispatch({ 7 | type: Constants.TIMER_START, 8 | timeEntry: timeEntry, 9 | }); 10 | }; 11 | }, 12 | 13 | stop: () => { 14 | return dispatch => { 15 | dispatch({ 16 | type: Constants.TIMER_STOP, 17 | }); 18 | }; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /web/static/js/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { browserHistory } from 'react-router'; 4 | import { syncHistoryWithStore } from 'react-router-redux'; 5 | import configureStore from './store'; 6 | import Root from './containers/root'; 7 | 8 | const store = configureStore(browserHistory); 9 | const history = syncHistoryWithStore(browserHistory, store); 10 | 11 | const target = document.getElementById('main_wrapper'); 12 | const node = ; 13 | 14 | ReactDOM.render(node, target); 15 | -------------------------------------------------------------------------------- /web/static/js/components/reports/bar.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import moment from 'moment'; 3 | import classnames from 'classnames'; 4 | import { formatReportDuration } from '../../utils'; 5 | 6 | export default class ChartBar extends React.Component { 7 | render() { 8 | const { id, date, duration } = this.props; 9 | 10 | const height = duration === 0 ? 2 : (duration * 100) / 36000; 11 | const durationText = formatReportDuration(moment.duration(duration * 1000)); 12 | const labelText = moment(date, 'YYYY-MM-DD').format('ddd
Do MMM'); 13 | 14 | const barClasses = classnames({ 15 | bar: true, 16 | 'zero-col': duration === 0, 17 | }); 18 | 19 | return ( 20 |
  • 21 |
    22 | 23 | {durationText} 24 |
    25 |
  • 26 | ); 27 | } 28 | } 29 | 30 | ChartBar.propTypes = { 31 | }; 32 | -------------------------------------------------------------------------------- /web/static/js/components/reports/container.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import moment from 'moment'; 3 | import classnames from 'classnames'; 4 | import { formatDuration} from '../../utils'; 5 | import ChartGrid from './grid'; 6 | import ChartBar from './bar'; 7 | 8 | export default class ReportContainer extends React.Component { 9 | _renderTotalTime(data) { 10 | const { total_duration } = data; 11 | 12 | return formatDuration(moment.duration(total_duration * 1000)); 13 | } 14 | 15 | _renderBars(data, hideLabels) { 16 | const { days } = data; 17 | 18 | const barItems = days.map((item) => { 19 | return ( 20 | 24 | ); 25 | }); 26 | 27 | return ( 28 |
      29 | {barItems} 30 |
    31 | ); 32 | } 33 | 34 | render() { 35 | const { data, hideLabels } = this.props; 36 | 37 | if (data == null) return false; 38 | 39 | const chartClasses = classnames({ 40 | 'barchart-chart': true, 41 | 'hide-labels': hideLabels, 42 | }); 43 | 44 | return ( 45 |
    46 |
    47 | Total {::this._renderTotalTime(data)} 48 |
    49 |
    50 |
    51 |
    52 | 53 | {::this._renderBars(data, hideLabels)} 54 |
    55 |
    56 |
    57 |
    58 | ); 59 | } 60 | } 61 | 62 | ReportContainer.propTypes = { 63 | }; 64 | -------------------------------------------------------------------------------- /web/static/js/components/reports/grid.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | 3 | export default class ChartGrid extends React.Component { 4 | render() { 5 | return ( 6 |
      7 |
    • 8 | 10 h 9 |
    • 10 |
    • 11 | 8 h 12 |
    • 13 |
    • 14 | 6 h 15 |
    • 16 |
    • 17 | 4 h 18 |
    • 19 |
    • 20 | 2 h 21 |
    • 22 |
    23 | ); 24 | } 25 | } 26 | 27 | ChartGrid.propTypes = { 28 | }; 29 | -------------------------------------------------------------------------------- /web/static/js/components/reports/range_selector.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import PageClick from 'react-page-click'; 3 | import { 4 | fetchData, 5 | showRangeSelector } from '../../actions/reports'; 6 | 7 | export default class RangeSelector extends React.Component { 8 | _renderDropdown(show) { 9 | if (!show) return false; 10 | 11 | return ( 12 | 13 |
      14 |
    • 15 | This week 16 |
    • 17 |
    • 18 | Last two weeks 19 |
    • 20 |
    • 21 | This month 22 |
    • 23 |
    24 |
    25 | ); 26 | } 27 | 28 | _fetchOneWeek(e) { 29 | e.preventDefault(); 30 | this._fetch(1); 31 | } 32 | 33 | _fetchTwoWeeks(e) { 34 | e.preventDefault(); 35 | this._fetch(2); 36 | } 37 | 38 | _fetchFourWeeks(e) { 39 | e.preventDefault(); 40 | this._fetch(4); 41 | } 42 | 43 | _fetch(numberOfWeeks) { 44 | const { dispatch, channel } = this.props; 45 | 46 | dispatch(fetchData(channel, numberOfWeeks)); 47 | } 48 | 49 | _hideDropdown() { 50 | const { dispatch } = this.props; 51 | 52 | dispatch(showRangeSelector(false)); 53 | } 54 | 55 | _handleShowClick(e) { 56 | const { dispatch, show } = this.props; 57 | 58 | if (show) return false; 59 | 60 | dispatch(showRangeSelector(true)); 61 | } 62 | 63 | render() { 64 | const { dispatch, show, text } = this.props; 65 | 66 | return ( 67 |
    68 |
      69 |
    • 70 |

      {text}

      71 | {::this._renderDropdown(show)} 72 |
    • 73 |
    74 |
    75 | ); 76 | } 77 | } 78 | 79 | RangeSelector.propTypes = { 80 | }; 81 | -------------------------------------------------------------------------------- /web/static/js/components/time_entries/item.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import moment from 'moment'; 3 | import PageClick from 'react-page-click'; 4 | import classnames from 'classnames'; 5 | import { formatDuration } from '../../utils'; 6 | import { 7 | displayDropdown, 8 | removeTimeEntry, 9 | selectTimeEntry, 10 | deselectTimeEntry, 11 | editItem, 12 | replaceTimeEntry} from '../../actions/time_entries'; 13 | 14 | export default class TimeEntryItem extends React.Component { 15 | _renderDuration(duration) { 16 | return formatDuration(moment.duration(duration * 1000)); 17 | } 18 | 19 | _handleContinueClick(e) { 20 | e.preventDefault(); 21 | 22 | const data = { 23 | id: this.props.id, 24 | description: this.props.description, 25 | duration: this.props.duration, 26 | restarted_at: this.props.restarted_at, 27 | started_at: this.props.started_at, 28 | stopped_at: this.props.stopped_at, 29 | updated_at: this.props.updated_at, 30 | }; 31 | 32 | this.props.continueClick(data); 33 | } 34 | 35 | _handleToggleDropdownClick(e) { 36 | const { id, dispatch } = this.props; 37 | 38 | dispatch(displayDropdown(id)); 39 | } 40 | 41 | _handlePageClick() { 42 | const { dispatch } = this.props; 43 | 44 | dispatch(displayDropdown(0)); 45 | } 46 | 47 | _renderDropdown() { 48 | const { dispatch, displayDropdown, id, channel, section } = this.props; 49 | 50 | if (!displayDropdown) return false; 51 | 52 | const onDeleteClick = (e) => { 53 | e.preventDefault(); 54 | 55 | if (confirm('Are you sure you want to delete this entry?')) { 56 | channel.push('time_entry:delete', { id: id }) 57 | .receive('ok', (data) => { 58 | dispatch(removeTimeEntry(section, data)); 59 | }); 60 | 61 | } 62 | }; 63 | 64 | return ( 65 | 66 |
    67 |
      68 |
    • 69 | Delete 70 |
    • 71 |
    72 |
    73 |
    74 | ); 75 | } 76 | 77 | _handleCheckboxChange() { 78 | const { checkbox } = this.refs; 79 | const { section, id, dispatch } = this.props; 80 | 81 | checkbox.checked ? dispatch(selectTimeEntry(section, id)) : dispatch(deselectTimeEntry(section, id)); 82 | } 83 | 84 | _renderDescription() { 85 | const { inEditMode, description } = this.props; 86 | const descriptionText = description != '' && description != null ? description : '(no description)'; 87 | 88 | if (inEditMode) { 89 | return ( 90 | 91 | 92 | 93 | ); 94 | } else { 95 | return descriptionText; 96 | } 97 | } 98 | 99 | _handleEditClick() { 100 | const { id, dispatch } = this.props; 101 | 102 | dispatch(editItem(id)); 103 | } 104 | 105 | _removeEditMode() { 106 | const { dispatch } = this.props; 107 | 108 | dispatch(editItem(null)); 109 | } 110 | 111 | _handleDescriptionKeyUp(e) { 112 | if (e.which != 13) return false; 113 | 114 | const { id, dispatch, channel } = this.props; 115 | 116 | channel.push('time_entry:update', { id: id, description: e.target.value.trim() }) 117 | .receive('ok', (data) => { 118 | dispatch(replaceTimeEntry(data)); 119 | }); 120 | } 121 | 122 | render() { 123 | const { id, duration, displayDropdown, selected } = this.props; 124 | 125 | const checkboxClasses = classnames({ 126 | 'checkbox-container': true, 127 | active: displayDropdown, 128 | }); 129 | 130 | return ( 131 |
  • 132 |
    133 | 134 | 135 | 136 | {::this._renderDropdown()} 137 |
    138 |
    139 | {::this._renderDescription()} 140 |
    141 |
    142 | 143 |
    144 |
    145 | {::this._renderDuration(duration)} 146 |
    147 |
  • 148 | ); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /web/static/js/components/timer/index.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import Tock from 'tocktimer'; 3 | import moment from 'moment'; 4 | import { connect } from 'react-redux'; 5 | import classnames from 'classnames'; 6 | import Actions from '../../actions/timer'; 7 | import { appendTimeEntry } from '../../actions/time_entries'; 8 | import { 9 | timexDateTimeToString, 10 | setDocumentTitle, 11 | formatDuration } from '../../utils'; 12 | 13 | export default class Timer extends React.Component { 14 | componentDidMount() { 15 | const { timeEntry } = this.props; 16 | 17 | if (timeEntry != null) this._startTimer(timeEntry); 18 | } 19 | 20 | componentDidUpdate() { 21 | const { timeEntry, started } = this.props; 22 | 23 | if (timeEntry != null && !started) this._startTimer(timeEntry); 24 | } 25 | 26 | componentWillUnmount() { 27 | if (!this.timer) return false; 28 | 29 | this.timer.stop(); 30 | this.timer = null; 31 | } 32 | 33 | _handleButtonClick() { 34 | this.props.started ? this.stop() : this._start(); 35 | } 36 | 37 | _start() { 38 | const startedAt = moment.utc().toISOString(); 39 | const { time, description } = this.refs; 40 | const { start, dispatch, duration, channel } = this.props; 41 | 42 | const timeEntry = { 43 | started_at: startedAt, 44 | description: description.value.trim(), 45 | workspace_id: null, 46 | }; 47 | 48 | channel.push('time_entry:start', timeEntry) 49 | .receive('ok', (data) => { 50 | this._startTimer(data); 51 | }); 52 | } 53 | 54 | stop() { 55 | const stoppedAt = moment().toISOString(); 56 | const { timeEntry, channel, dispatch } = this.props; 57 | 58 | timeEntry.stopped_at = stoppedAt; 59 | 60 | channel.push('time_entry:stop', timeEntry) 61 | .receive('ok', (data) => { 62 | dispatch(appendTimeEntry(data)); 63 | 64 | this._resetTimer(); 65 | }); 66 | 67 | } 68 | 69 | _handleDiscardClick(e) { 70 | e.preventDefault(); 71 | 72 | const { timeEntry, channel } = this.props; 73 | 74 | channel.push('time_entry:discard', timeEntry) 75 | .receive('ok', (data) => { 76 | this._resetTimer(); 77 | }); 78 | } 79 | 80 | _resetTimer() { 81 | const { time, description } = this.refs; 82 | const { dispatch } = this.props; 83 | 84 | this.timer.stop(); 85 | this.timer = null; 86 | time.value = '0 sec'; 87 | description.value = ''; 88 | 89 | setDocumentTitle('Home'); 90 | dispatch(Actions.stop()); 91 | } 92 | 93 | _startTimer(timeEntry) { 94 | const { dispatch, started } = this.props; 95 | const { time, description, duration } = this.refs; 96 | 97 | description.value = timeEntry.description; 98 | 99 | const timer = new Tock({ 100 | start: '00:00:00', 101 | callback: () => { 102 | const currentTime = moment.duration(timer.lap()); 103 | const timeText = formatDuration(currentTime); 104 | time.value = timeText; 105 | 106 | setDocumentTitle(`${timeText} - ${description.value.trim()}`); 107 | }, 108 | }); 109 | 110 | if (timeEntry.restarted_at != null) { 111 | const timeEntryStart = moment.utc(timexDateTimeToString(timeEntry.restarted_at), 'YYYY-M-D H:m:s'); 112 | const initialTime = moment.utc().diff(moment(timeEntryStart), 'milliseconds'); 113 | 114 | timer.start((timeEntry.duration * 1000) + initialTime); 115 | } else { 116 | const timeEntryStart = moment.utc(timexDateTimeToString(timeEntry.started_at), 'YYYY-M-D H:m:s'); 117 | const initialTime = moment.utc().diff(moment(timeEntryStart), 'milliseconds'); 118 | 119 | timer.start(initialTime); 120 | } 121 | 122 | this.timer = timer; 123 | 124 | dispatch(Actions.start(timeEntry)); 125 | } 126 | 127 | _timeValue(value) { 128 | if (value < 10) return '0' + value; 129 | 130 | return value; 131 | } 132 | 133 | _buttonText() { 134 | const { started } = this.props; 135 | 136 | return started ? 'Stop' : 'Start'; 137 | } 138 | 139 | _updateTimeEntryDescription() { 140 | const { timeEntry, channel } = this.props; 141 | const { description } = this.refs; 142 | 143 | if (timeEntry == null) return false; 144 | if (timeEntry != null && timeEntry.description === description.value) return false; 145 | 146 | channel.push('time_entry:update', { description: description.value.trim() }); 147 | } 148 | 149 | _handleDescriptionKeyUp(e) { 150 | if (e.which != 13) return false; 151 | 152 | const { started } = this.props; 153 | 154 | if (started) { 155 | this._updateTimeEntryDescription(); 156 | } else { 157 | this._start(); 158 | } 159 | } 160 | 161 | _renderDiscardLink() { 162 | const { started } = this.props; 163 | 164 | if (!started) return false; 165 | 166 | return ( 167 | Discard 168 | ); 169 | } 170 | 171 | render() { 172 | const { started } = this.props; 173 | 174 | const buttonClasses = classnames({ 175 | 'btn-start': true, 176 | started: !started, 177 | }); 178 | 179 | return ( 180 |
    181 |
    182 | {::this._renderDiscardLink()} 183 |
    184 |
    185 |
    186 | 193 |
    194 |
    195 | 202 |
    203 |
    204 | 208 |
    209 |
    210 |
    211 | ); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /web/static/js/constants/index.js: -------------------------------------------------------------------------------- 1 | const Constants = { 2 | USER_SIGNED_IN: 'USER_SIGNED_IN', 3 | CURRENT_USER: 'CURRENT_USER', 4 | USER_SIGNED_OUT: 'USER_SIGNED_OUT', 5 | SESSIONS_ERROR: 'SESSIONS_ERROR', 6 | REGISTRATIONS_ERROR: 'REGISTRATIONS_ERROR', 7 | 8 | // Header 9 | HEADER_SHOW_DROPDOWN: 'HEADER_SHOW_DROPDOWN', 10 | 11 | // Timer action types 12 | TIMER_START: 'TIMER_START', 13 | TIMER_STOP: 'TIMER_STOP', 14 | TIMER_SET_TIME_ENTRY: 'TIMER_SET_TIME_ENTRY', 15 | 16 | // Time entries 17 | TIME_ENTRIES_FETCH_START: 'TIME_ENTRIES_FETCH_START', 18 | TIME_ENTRIES_FETCH_SUCCESS: 'TIME_ENTRIES_FETCH_SUCCESS', 19 | TIME_ENTRIES_APPEND_ITEM: 'TIME_ENTRIES_APPEND_ITEM', 20 | TIME_ENTRIES_REMOVE_ITEM: 'TIME_ENTRIES_REMOVE_ITEM', 21 | TIME_ENTRIES_REMOVE_ITEMS: 'TIME_ENTRIES_REMOVE_ITEMS', 22 | TIME_ENTRIES_CONTINUE_ITEM: 'TIME_ENTRIES_CONTINUE_ITEM', 23 | TIME_ENTRIES_DISPLAY_DROPDOWN_FOR: 'TIME_ENTRIES_DISPLAY_DROPDOWN_FOR', 24 | TIME_ENTRIES_SELECT_ITEM: 'TIME_ENTRIES_SELECT_ITEM', 25 | TIME_ENTRIES_DESELECT_ITEM: 'TIME_ENTRIES_DESELECT_ITEM', 26 | TIME_ENTRIES_SELECT_SECTION: 'TIME_ENTRIES_SELECT_SECTION', 27 | TIME_ENTRIES_DESELECT_SECTION: 'TIME_ENTRIES_DESELECT_SECTION', 28 | TIME_ENTRIES_EDIT_ITEM: 'TIME_ENTRIES_EDIT_ITEM', 29 | TIME_ENTRIES_REPLACE_ITEM: 'TIME_ENTRIES_REPLACE_ITEM', 30 | 31 | // Reports 32 | REPORTS_DATA_FECTH_START: 'REPORTS_DATA_FECTH_START', 33 | REPORTS_DATA_FECTH_SUCCESS: 'REPORTS_DATA_FECTH_SUCCESS', 34 | REPORTS_SHOW_RANGE_SELECTOR: 'REPORTS_SHOW_RANGE_SELECTOR', 35 | }; 36 | 37 | export default Constants; 38 | -------------------------------------------------------------------------------- /web/static/js/containers/authenticated.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Favicon from 'react-favicon'; 4 | 5 | import Header from '../layouts/header'; 6 | import { 7 | faviconData, 8 | creditsText } from '../utils'; 9 | 10 | class AuthenticatedContainer extends React.Component { 11 | _renderFavicon() { 12 | const { timer } = this.props; 13 | 14 | const url = timer.started ? faviconData.on : faviconData.off; 15 | 16 | return ( 17 | 18 | ); 19 | } 20 | 21 | render() { 22 | const { currentUser, dispatch, socket } = this.props; 23 | 24 | if (!currentUser) return false; 25 | 26 | return ( 27 |
    28 | {::this._renderFavicon()} 29 |
    30 | 31 |
    32 | {this.props.children} 33 |
    34 |
    35 |
    36 | {creditsText()} 37 |
    38 |
    39 |
    40 | ); 41 | } 42 | } 43 | 44 | const mapStateToProps = (state) => ({ 45 | currentUser: state.session.currentUser, 46 | socket: state.session.socket, 47 | channel: state.session.channel, 48 | timer: state.timer, 49 | }); 50 | 51 | export default connect(mapStateToProps)(AuthenticatedContainer); 52 | -------------------------------------------------------------------------------- /web/static/js/containers/root.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { Router } from 'react-router'; 4 | import invariant from 'invariant'; 5 | import { RoutingContext } from 'react-router'; 6 | import configRoutes from '../routes'; 7 | 8 | export default class Root extends React.Component { 9 | _renderRouter(store) { 10 | invariant(this.props.routerHistory, 11 | ' needs either a routingContext or routerHistory to render.' 12 | ); 13 | 14 | return ( 15 | 16 | {configRoutes(store)} 17 | 18 | ); 19 | } 20 | 21 | render() { 22 | const { store } = this.props; 23 | 24 | return ( 25 | 26 | {this._renderRouter(store)} 27 | 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web/static/js/layouts/header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link, IndexLink } from 'react-router'; 4 | import PageClick from 'react-page-click'; 5 | import { routeActions } from 'react-router-redux'; 6 | import classnames from 'classnames'; 7 | 8 | import SessionActions from '../actions/sessions'; 9 | import {showDropdown} from '../actions/header'; 10 | 11 | class Header extends React.Component { 12 | _renderCurrentUser() { 13 | const { currentUser, showMenu } = this.props; 14 | 15 | if (!currentUser) { 16 | return false; 17 | } 18 | 19 | const classes = classnames({ 20 | 'current-user': true, 21 | visible: showMenu, 22 | }); 23 | 24 | return ( 25 | 26 | {currentUser.first_name} 27 | 28 | ); 29 | } 30 | 31 | _handleShowDropdownClick(e) { 32 | e.preventDefault(); 33 | 34 | if (showMenu) return false; 35 | 36 | const { dispatch, showMenu } = this.props; 37 | 38 | dispatch(showDropdown(!showMenu)); 39 | } 40 | 41 | _renderDropdown(show) { 42 | if (!show) return false; 43 | 44 | return ( 45 | 46 |
      47 |
    • 48 | {this._renderSignOutLink()} 49 |
    • 50 |
    51 |
    52 | ); 53 | } 54 | 55 | _handlePageClick() { 56 | const { dispatch } = this.props; 57 | 58 | dispatch(showDropdown(false)); 59 | } 60 | 61 | _renderSignOutLink() { 62 | if (!this.props.currentUser) { 63 | return false; 64 | } 65 | 66 | return ( 67 | Sign out 68 | ); 69 | } 70 | 71 | _handleSignOutClick(e) { 72 | e.preventDefault(); 73 | 74 | const { dispatch, socket, channel } = this.props; 75 | 76 | dispatch(SessionActions.signOut(socket, channel)); 77 | } 78 | 79 | render() { 80 | const { showMenu } = this.props; 81 | 82 | return ( 83 |
    84 |
    85 |
    86 | 87 | phoenix toggl 88 | 89 |
      90 |
    • 91 | Timer 92 |
    • 93 |
    • 94 | Reports 95 |
    • 96 |
    97 |
    98 |
    99 |
      100 |
    • 101 | {this._renderCurrentUser()} 102 | {this._renderDropdown(showMenu)} 103 |
    • 104 |
    105 |
    106 |
    107 |
    108 | ); 109 | } 110 | } 111 | 112 | const mapStateToProps = (state) => ( 113 | { ...state.header, ...state.session } 114 | ); 115 | 116 | export default connect(mapStateToProps)(Header); 117 | -------------------------------------------------------------------------------- /web/static/js/layouts/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export default class MainLayout extends React.Component { 5 | constructor() { 6 | super(); 7 | } 8 | 9 | render() { 10 | return ( 11 |
    12 | {this.props.children} 13 |
    14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /web/static/js/reducers/header.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const initialState = { 4 | showMenu: false, 5 | }; 6 | 7 | export default function reducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case Constants.HEADER_SHOW_DROPDOWN: 10 | return { ...state, showMenu: action.show }; 11 | 12 | case Constants.USER_SIGNED_OUT: 13 | return initialState; 14 | 15 | default: 16 | return state; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /web/static/js/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { routerReducer } from 'react-router-redux'; 3 | import session from './session'; 4 | import registration from './registration'; 5 | import header from './header'; 6 | import timer from './timer'; 7 | import timeEntries from './time_entries'; 8 | import reports from './reports'; 9 | 10 | export default combineReducers({ 11 | routing: routerReducer, 12 | session: session, 13 | registration: registration, 14 | header: header, 15 | timer: timer, 16 | timeEntries: timeEntries, 17 | reports: reports, 18 | }); 19 | -------------------------------------------------------------------------------- /web/static/js/reducers/registration.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const initialState = { 4 | errors: null, 5 | }; 6 | 7 | export default function reducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case Constants.REGISTRATIONS_ERROR: 10 | return { ...state, errors: action.errors }; 11 | 12 | default: 13 | return state; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /web/static/js/reducers/reports.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const initialState = { 4 | data: null, 5 | fetching: true, 6 | filter: { 7 | text: 'This week', 8 | numberOfWeeks: 1, 9 | show: false, 10 | }, 11 | }; 12 | 13 | function filterText(numberOfWeeks) { 14 | switch (numberOfWeeks) { 15 | case 1: 16 | return 'This week'; 17 | case 2: 18 | return 'Last two weeks'; 19 | case 4: 20 | return 'This month'; 21 | } 22 | } 23 | 24 | export default function reducer(state = initialState, action = {}) { 25 | switch (action.type) { 26 | case Constants.REPORTS_DATA_FECTH_START: 27 | return { ...state, fetching: true }; 28 | 29 | case Constants.REPORTS_DATA_FECTH_SUCCESS: 30 | const text = filterText(action.numberOfWeeks); 31 | return { ...state, data: action.data, fetching: false, filter: { ...state.filter, numberOfWeeks: action.numberOfWeeks, show: false, text: text } }; 32 | 33 | case Constants.REPORTS_SHOW_RANGE_SELECTOR: 34 | return { ...state, filter: { ...state.filter, show: action.show } }; 35 | 36 | case Constants.USER_SIGNED_OUT: 37 | return initialState; 38 | 39 | default: 40 | return state; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/static/js/reducers/session.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const initialState = { 4 | currentUser: null, 5 | socket: null, 6 | channel: null, 7 | error: null, 8 | }; 9 | 10 | export default function reducer(state = initialState, action = {}) { 11 | switch (action.type) { 12 | case Constants.CURRENT_USER: 13 | return { ...state, currentUser: action.currentUser, socket: action.socket, channel: action.channel, error: null }; 14 | 15 | case Constants.USER_SIGNED_OUT: 16 | return initialState; 17 | 18 | case Constants.SESSIONS_ERROR: 19 | return { ...state, error: action.error }; 20 | 21 | default: 22 | return state; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /web/static/js/reducers/time_entries.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const initialState = { 4 | items: [], 5 | displayDropdownFor: null, 6 | selectedItems: {}, 7 | editItem: null, 8 | }; 9 | 10 | export default function reducer(state = initialState, action = {}) { 11 | let newItems = []; 12 | let newSelectedItems = []; 13 | let index = 0; 14 | 15 | switch (action.type) { 16 | case Constants.TIME_ENTRIES_FETCH_SUCCESS: 17 | return { ...state, items: action.items }; 18 | 19 | case Constants.TIME_ENTRIES_APPEND_ITEM: 20 | const items = [action.item].concat(state.items); 21 | 22 | return { ...state, items: items }; 23 | 24 | case Constants.TIME_ENTRIES_REMOVE_ITEM: 25 | newItems = [...state.items]; 26 | index = newItems.findIndex((item) => item.id === action.item.id); 27 | 28 | newItems.splice(index, 1); 29 | 30 | newSelectedItems = { ...state.selectedItems }; 31 | delete newSelectedItems[action.section]; 32 | 33 | return { ...state, items: newItems, displayDropdownFor: null, selectedItems: newSelectedItems }; 34 | 35 | case Constants.TIME_ENTRIES_REMOVE_ITEMS: 36 | newItems = [...state.items].filter((item) => action.ids.indexOf(item.id) === -1); 37 | 38 | newSelectedItems = { ...state.selectedItems }; 39 | delete newSelectedItems[action.section]; 40 | 41 | return { ...state, items: newItems, displayDropdownFor: null, selectedItems: newSelectedItems }; 42 | 43 | case Constants.TIME_ENTRIES_DISPLAY_DROPDOWN_FOR: 44 | return { ...state, displayDropdownFor: action.id }; 45 | 46 | case Constants.TIME_ENTRIES_SELECT_ITEM: 47 | newSelectedItems = { ...state.selectedItems }; 48 | const section = newSelectedItems[action.section]; 49 | 50 | if (section === undefined) { 51 | newSelectedItems[action.section] = [action.id]; 52 | } else { 53 | const sectionItems = [...section, action.id]; 54 | 55 | newSelectedItems[action.section] = sectionItems; 56 | } 57 | 58 | return { ...state, selectedItems: newSelectedItems }; 59 | 60 | case Constants.TIME_ENTRIES_DESELECT_ITEM: 61 | newSelectedItems = { ...state.selectedItems }; 62 | index = newSelectedItems[action.section].indexOf(action.index); 63 | 64 | newSelectedItems[action.section].splice(index, 1); 65 | 66 | return { ...state, selectedItems: newSelectedItems }; 67 | 68 | case Constants.TIME_ENTRIES_SELECT_SECTION: 69 | newSelectedItems = { ...state.selectedItems }; 70 | newSelectedItems[action.section] = action.ids; 71 | 72 | return { ...state, selectedItems: newSelectedItems }; 73 | 74 | case Constants.TIME_ENTRIES_DESELECT_SECTION: 75 | newSelectedItems = { ...state.selectedItems }; 76 | delete newSelectedItems[action.section]; 77 | 78 | return { ...state, selectedItems: newSelectedItems }; 79 | 80 | case Constants.TIME_ENTRIES_EDIT_ITEM: 81 | return { ...state, editItem: action.id }; 82 | 83 | case Constants.TIME_ENTRIES_REPLACE_ITEM: 84 | newItems = [...state.items]; 85 | index = newItems.findIndex((item) => item.id === action.item.id); 86 | newItems.splice(index, 1, action.item); 87 | 88 | return { ...state, items: newItems, editItem: action.id }; 89 | case Constants.USER_SIGNED_OUT: 90 | return initialState; 91 | 92 | default: 93 | return state; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /web/static/js/reducers/timer.js: -------------------------------------------------------------------------------- 1 | import Constants from '../constants'; 2 | 3 | const initialState = { 4 | start: '00:00:00', 5 | stop: null, 6 | duration: 0, 7 | started: false, 8 | timeEntry: null, 9 | }; 10 | 11 | export default function reducer(state = initialState, action = {}) { 12 | switch (action.type) { 13 | case Constants.TIMER_START: 14 | return { ...state, started: true, timeEntry: action.timeEntry }; 15 | 16 | case Constants.TIMER_STOP: 17 | return { ...initialState }; 18 | 19 | case Constants.TIMER_SET_TIME_ENTRY: 20 | const { timeEntry } = action; 21 | 22 | return { ...state, timeEntry: timeEntry }; 23 | 24 | case Constants.USER_SIGNED_OUT: 25 | return initialState; 26 | 27 | default: 28 | return state; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web/static/js/routes/index.js: -------------------------------------------------------------------------------- 1 | import { IndexRoute, Route } from 'react-router'; 2 | import React from 'react'; 3 | import MainLayout from '../layouts/main'; 4 | import AuthenticatedContainer from '../containers/authenticated'; 5 | import HomeIndexView from '../views/home'; 6 | import RegistrationsNewView from '../views/registrations/new'; 7 | import SessionsNewView from '../views/sessions/new'; 8 | import ReportsIndexView from '../views/reports/index'; 9 | import Actions from '../actions/sessions'; 10 | 11 | export default function configRoutes(store) { 12 | const _ensureAuthenticated = (nextState, replace, callback) => { 13 | const { dispatch } = store; 14 | const { session } = store.getState(); 15 | const { currentUser } = session; 16 | 17 | if (!currentUser && localStorage.getItem('phoenixAuthToken')) { 18 | dispatch(Actions.currentUser()); 19 | } else if (!localStorage.getItem('phoenixAuthToken')) { 20 | replace('/sign_in'); 21 | } 22 | 23 | callback(); 24 | }; 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /web/static/js/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import createLogger from 'redux-logger'; 3 | import thunkMiddleware from 'redux-thunk'; 4 | import reducers from '../reducers'; 5 | import { routerMiddleware } from 'react-router-redux'; 6 | 7 | const loggerMiddleware = createLogger({ 8 | level: 'info', 9 | collapsed: true, 10 | }); 11 | 12 | export default function configureStore(browserHistory) { 13 | const reduxRouterMiddleware = routerMiddleware(browserHistory); 14 | const createStoreWithMiddleware = applyMiddleware(reduxRouterMiddleware, thunkMiddleware, loggerMiddleware)(createStore); 15 | 16 | return createStoreWithMiddleware(reducers); 17 | } 18 | -------------------------------------------------------------------------------- /web/static/js/utils/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import fetch from 'isomorphic-fetch'; 3 | import { polyfill } from 'es6-promise'; 4 | import moment from 'moment'; 5 | 6 | const defaultHeaders = { 7 | Accept: 'application/json', 8 | 'Content-Type': 'application/json', 9 | }; 10 | 11 | function buildHeaders() { 12 | const authToken = localStorage.getItem('phoenixAuthToken'); 13 | 14 | return { ...defaultHeaders, Authorization: authToken }; 15 | } 16 | 17 | export function checkStatus(response) { 18 | if (response.status >= 200 && response.status < 300) { 19 | return response; 20 | } else { 21 | var error = new Error(response.statusText); 22 | error.response = response; 23 | throw error; 24 | } 25 | } 26 | 27 | export function parseJSON(response) { 28 | return response.json(); 29 | } 30 | 31 | export function httpGet(url) { 32 | return fetch(url, { 33 | headers: buildHeaders(), 34 | }) 35 | .then(checkStatus) 36 | .then(parseJSON); 37 | } 38 | 39 | export function httpPost(url, data) { 40 | const body = JSON.stringify(data); 41 | 42 | return fetch(url, { 43 | method: 'post', 44 | headers: buildHeaders(), 45 | body: body, 46 | }) 47 | .then(checkStatus) 48 | .then(parseJSON); 49 | } 50 | 51 | export function httpDelete(url) { 52 | const authToken = localStorage.getItem('phoenixAuthToken'); 53 | 54 | return fetch(url, { 55 | method: 'delete', 56 | headers: buildHeaders(), 57 | }) 58 | .then(checkStatus) 59 | .then(parseJSON); 60 | } 61 | 62 | export function setDocumentTitle(title) { 63 | document.title = `${title} • Phoenix Toggl`; 64 | } 65 | 66 | export function renderErrorsFor(errors, ref) { 67 | if (!errors) return false; 68 | 69 | return errors.map((error, i) => { 70 | if (error[ref]) { 71 | return ( 72 |
    73 | {error[ref]} 74 |
    75 | ); 76 | } 77 | }); 78 | } 79 | 80 | export function timexDateTimeToString(date) { 81 | const { year, month, day, hour, minute, second } = date; 82 | 83 | return `${year}-${month}-${day} ${hour}:${minute}:${second}`; 84 | } 85 | 86 | export function timexDateToString(date) { 87 | const { year, month, day } = date; 88 | 89 | return `${year}-${month}-${day}`; 90 | } 91 | 92 | export function formatDuration(duration) { 93 | if (duration.hours() > 0) { 94 | return `${numberToString(duration.hours())}:${numberToString(duration.minutes())}:${numberToString(duration.seconds())}`; 95 | } else if (duration.minutes() > 0) { 96 | return `${numberToString(duration.minutes())}:${numberToString(duration.seconds())} min`; 97 | } else { 98 | return `${duration.seconds()} sec`; 99 | } 100 | } 101 | 102 | export function formatReportDuration(duration) { 103 | return `${duration.hours()}:${numberToString(duration.minutes())}`; 104 | } 105 | 106 | export const faviconData = { 107 | off: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADoAAAA6CAYAAADhu0ooAAAAAXNSR0IArs4c6QAABF1JREFUaAXlm0trE1EUx5sJBpwmC1cKduEmRtwkRYrtohtx58aCabsxTQJ+BLHFVRfSgp+gLvpACKUVKvgRirQi0myUFjcuUqgrlaTjA5r6/09mwsxkMq+m7czthZu5M/cx5zfn3MfMPYn1nULI5/OJ/v7+u7FYbATNZ3DMHB8fX0c6pUXetc6IvH3k7SG9h+PW4eHhh/X19X8s0MsQ61VjgEsC7hEEn0QcRbtywLYVAG8irgL6DaAbAdsxVTsxaLFYTANsmoAngDMJZTgh9Cri/PLy8lfDdd/JwKAAvCFJ0hzuOI4o+b6zvwpNFF9rNpszAP7mr2qrtG9Q9r9kMvkU1Z9Di5eD3DRoHWj2N+q+aDQaL/32Y1+g0OI1wG0gDgcVthf1ALyNOAbtHnhtzzPo1NTUnXg8/hYND3ht/JTL1Y6Ojh6urKx88nIfT32rXC6Poz9uhgiSbAOUibJ5AY27FYK5PoOpLiBecit71vmaTPlsNvunWq2+d7q/o+mWSqUJNLbq1ECI8iYWFxfXusnTFZR9kqYB0DMdWbsJ6nadIzKmn9FufdYWlKMrID+i8bAMPG6cen4NsEN2o3HHYMR5ElrciCAkYQcoOxl0cv3YAcrFAAqf6zypCxfkSNm1BY2pusl0uaxDwS+IkeiXJhLDCfsr4m3jctGkUfTLuahDkpcMZDGw97U1Cm2mkbmLTBO8sXDE0hiXmregVfWtpw2FpzAtECR1ImlMqn5UjfKlOZVKfceVoC/LamMh/FHq9fpVvryrGuWXAQEh+dxlja3VH6HiyRBqoyci6WwxTq4w2x+k70nL4WuE5ntFkmWZiwNRIfnYgSgPY0aRCCp0ICMHo4zQlC24DOca4UHJKGFNyC/oQgcy0nS5TSB6SF0oUNG1qfJRo9zVEj3ULw4oht590dVJRk4v3IQVOpCRpis8KBklfG7YFlqdgINGtyRFUQiqCAyr0C9C4oYqiLlTJmQgGxnZR6naqGwk+VaGzqaC0vsDLYhovjRbsrW+GfErmU7u+5GFuAKZyNYGZQIX53Gg94cooakxqTyq6TKlfdHuupEaQfo1/Ss9ZW+D8gRz6gyeAl1cIh3IQBYjhMmHAX4APwcHB7lJc89YKILpWWjznVFuk0aZQWclPJHIrpYoOxmMkEx3gGoLiDHk1ayFI3BeA+gYGayydoCyANR+QGcl2rq1QljPKStlpux2MtqCsiC9O9BXi3aVwniNsnbzSKG8psHICrCzs/OZzkpo5L41L0znGGGnl5aWXjnJ5AjKivTIyuVyu0g+AHCovMe0rvUY5rrgBMm89ta+W8EL4fTIh0D7h4kM4Sme+9RDGSiLU5+0Ks7VdI0VYMaNdDr9OpFI/MX1kbM2Zc1UZzFPPqlUKr+MsrmlPZuutSH6JGE7ji4uYrqaW4ELhcJNOCzT1ZXuAb3eUD7/Pw9YgenZIvTfQazAPKdfhLadzt10z3/wwQCzzY91dks4u/v4ufYfAVTmzzcg69gAAAAASUVORK5CYII=', 108 | on: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADoAAAA6CAYAAADhu0ooAAAAAXNSR0IArs4c6QAABDtJREFUaAXlm99PE0EQx2evKIVChIRDE4X4ohh9NEZ8QP4FTAzwqH+EAaOvRhP/CF+BmOi/QHgQY3zUSHwxoIm2JiA/K/YY57vtHu3Sa4+Kpbds0tzu/dibT+dmbndnTtF/KB+ITg+k/ZuUUreIeIgUDRGr87LtVkzduCUr2iCWn+Jvsl0iUksU8JuVfO7tNaLdoxZLHVWHWfK70hm6Kx1OitAjAtXZUN9M2/I3LDDRTH6LXvZTbrOhfqyL/hn0V7t/SbXRtCI12TCcJVTYFGgmnuECPTvzO/c53N9ApWHQ1XTPxbbUqadyz3FSymvg3vEvYd6Tk+cKwZ+Hvfm1L/Ev3D/z0KCwv8FO/4HAPRINdux31YQa0w4xP1nezj0/rB0fCjSb8c+llXolFw03ASvyFmK/i3nmO/1bue+RJ1kHYoOudfZdTyn1WjR5werjeJrMXwPmsZ7tn+/jCBDLtta6/PGU8hZaBhJk8odDJsh2JKDrGX8qRWq26fYYR3rxEZANMtY7veajK//WhHQ0U6+TVjgeEE/0bObmomSJBC3aJB7XJnvWKEnr7RePHPDeSJTNVgWFd5X3xruWssl6oDguDmqH6EY1b3zAGeE9iVdI4iABKg4KsoMBzfJyABSDAVHzsb4nywU8bB2y6wGNdWHFo1sc1p3+mBi7tGDCpthrIdi9Wj5crNCoHrsmxfmEVFUqwlAah4cHQ41iFuK10Sd5zivgwzOTVpGJwF6BrphZTwiFqZYzkFCKKEwzlRSkNYpJc0dG/RDbbGyy3KralvnszhafxeRdaxQrA85Baq1Sp2aTugYVtcryh5vFsHnFl6us8Thb1AgY1Ubav01tat5ZToAVeNTjlErsKCiucsAoNirrrs4XHvLE27oPKoyeXkF3XaMSJYBGdYjAaVZh9EwsxGVQMIZjXZdBwebpqJbjlGAUZyShO9eLMIozkvik60UYoVEJwjpehFGckUSanS9qyVMBLzrPKSkDClOYwUz/qpMTb2hQVhmWt7K9XjGgygvuapUXwKgHDEiMcBXUsJ2sxTGskiH7wzWtgsmk74RjXaS4SDQK2R9uFGHRTCWaELS0oh0ZSE0g/ZxZpYfs2kYNxIkJMunok+TxGPDEboWhPJIGjgqNYgcGEANd/fNyIJGrg8hBWtnMjtoJVwdAAXsiQvsARQ4AkpVk+CQpAQkpOlmDx6rlL4Ag9Lo2DrI7AsX37P2t2oasURkpkDkSFAeRt8PM06i3coGMtXKMIHtVG7WhdGIVqxdydnOzOW1B7DYeV8X3BXLWPmS3Y4HiohOR9AhQPP9IVoL7Rvs4C2SALLVs0pavpo3aJ8Oj4R1Fe/z4WDwy3gJyb8gQ5V1tmU079qNrLjBb51PNDajZrrf3XZZA8pSzHw8YULN1/nMQA1q+1QtuaX+4GE2P/4EPViSX87lFe5xa3nej9b9Uzbh0HJ7RFAAAAABJRU5ErkJggg==', 109 | }; 110 | 111 | export function creditsText() { 112 | return ( 113 |

    114 | Toggl tribute for educational purposes 115 | crafted with ♥ by @bigardone 116 |

    117 | ); 118 | } 119 | 120 | function numberToString(number) { 121 | return number > 9 ? number : `0${number}`; 122 | } 123 | -------------------------------------------------------------------------------- /web/static/js/views/home/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import classnames from 'classnames'; 4 | import moment from 'moment'; 5 | import PageClick from 'react-page-click'; 6 | import { setDocumentTitle } from '../../utils'; 7 | import Timer from '../../components/timer'; 8 | import { 9 | fetchTimeEntries, 10 | continueTimeEntry, 11 | startTimer, 12 | displayDropdown, 13 | removeTimeEntries, 14 | selectSection, 15 | deselectSection 16 | } from '../../actions/time_entries'; 17 | import TimeEntryItem from '../../components/time_entries/item'; 18 | import { timexDateToString } from '../../utils'; 19 | 20 | class HomeIndexView extends React.Component { 21 | componentDidMount() { 22 | const { dispatch } = this.props; 23 | 24 | setDocumentTitle('Timer'); 25 | dispatch(fetchTimeEntries()); 26 | } 27 | 28 | _renderItems() { 29 | const { items, dispatch, channel, displayDropdownFor, timer, selectedItems } = this.props; 30 | 31 | const groups = this._buildItemGroups(items); 32 | 33 | return Object.keys(groups).map((date) => { 34 | const items = groups[date]; 35 | const itemsIds = items.map((item) => item.id); 36 | const header = this._headerText(date); 37 | const showDropdown = displayDropdownFor === header; 38 | const selected = selectedItems[header] != undefined; 39 | 40 | const onContinueClick = (timeEntry) => { 41 | if (timer.started) this.refs.timer.stop(); 42 | 43 | if (header === 'Today') { 44 | const restartedAt = moment().toISOString(); 45 | const item = { ...timeEntry, restarted_at: restartedAt }; 46 | 47 | channel.push('time_entry:restart', item) 48 | .receive('ok', (data) => { 49 | dispatch(continueTimeEntry(data)); 50 | }); 51 | 52 | } else { 53 | const newTimeEntry = { 54 | started_at: moment.utc().toISOString(), 55 | description: timeEntry.description, 56 | workspace_id: null, 57 | }; 58 | 59 | channel.push('time_entry:start', newTimeEntry) 60 | .receive('ok', (data) => { 61 | dispatch(startTimer(data)); 62 | }); 63 | } 64 | }; 65 | 66 | const onToggleDropdownClick = () => { 67 | dispatch(displayDropdown(header)); 68 | }; 69 | 70 | const onCheckboxChange = (e) => { 71 | const checked = e.target.checked; 72 | 73 | checked ? dispatch(selectSection(header, itemsIds)) : dispatch(deselectSection(header, itemsIds)); 74 | }; 75 | 76 | return ( 77 |
    78 |
    79 |
    80 | 81 | 82 | 83 | {::this._renderDropdown(header, showDropdown)} 84 |
    85 |
    86 | {header} 87 | {::this._renderTotalTime(items)} 88 |
    89 |
    90 |
      91 | {::this._itemNodes(header, items, onContinueClick)} 92 |
    93 |
    94 | ); 95 | }); 96 | } 97 | 98 | _headerText(dateKey) { 99 | const date = moment(dateKey, 'YYYY-MM-DD'); 100 | 101 | switch (moment().diff(date, 'days')) { 102 | case 0: 103 | return 'Today'; 104 | case 1: 105 | return 'Yesterday'; 106 | default: 107 | return date.format('ddd, DD MMM'); 108 | } 109 | } 110 | 111 | _renderDropdown(section, showDropdown) { 112 | const { dispatch, channel, selectedItems } = this.props; 113 | 114 | if (!showDropdown) return false; 115 | 116 | const onDeleteClick = (e) => { 117 | e.preventDefault(); 118 | 119 | if (confirm('Are you sure you want to delete this entry?')) { 120 | channel.push('time_entry:delete', { id: selectedItems[section] }) 121 | .receive('ok', (data) => { 122 | dispatch(removeTimeEntries(section, data.ids)); 123 | }); 124 | 125 | } 126 | }; 127 | 128 | return ( 129 | 130 |
    131 |
      132 |
    • 133 | Delete 134 |
    • 135 |
    136 |
    137 |
    138 | ); 139 | } 140 | 141 | _itemNodes(section, items, continueCallback) { 142 | const { displayDropdownFor, dispatch, channel, selectedItems, editItem } = this.props; 143 | 144 | return items.map((item) => { 145 | const displayDropdown = item.id === displayDropdownFor; 146 | const selected = selectedItems[section] && selectedItems[section].indexOf(item.id) != -1; 147 | const inEditMode = item.id === editItem; 148 | 149 | return ( 150 | 160 | ); 161 | }); 162 | } 163 | 164 | _buildItemGroups(items) { 165 | const groups = {}; 166 | 167 | items.forEach((item) => { 168 | const key = timexDateToString(item.started_at); 169 | 170 | groups[key] = groups[key] || []; 171 | groups[key].push(item); 172 | }); 173 | 174 | return groups; 175 | } 176 | 177 | _renderTotalTime(items) { 178 | const { duration } = items.reduce((prev, curr) => {return { duration: prev.duration + curr.duration };}, { duration: 0 }); 179 | const momentDuration = moment.duration(duration * 1000); 180 | 181 | return `${momentDuration.hours()} h ${momentDuration.minutes()} min`; 182 | } 183 | 184 | _handlePageClick() { 185 | const { dispatch } = this.props; 186 | 187 | dispatch(displayDropdown(0)); 188 | } 189 | 190 | render() { 191 | const { timer, channel, dispatch } = this.props; 192 | 193 | return ( 194 |
    195 |
    196 | 201 | {::this._renderItems()} 202 |
    203 |
    204 | ); 205 | } 206 | } 207 | 208 | const mapStateToProps = (state) => ( 209 | { ...state.timeEntries, channel: state.session.channel, timer: state.timer } 210 | ); 211 | 212 | export default connect(mapStateToProps)(HomeIndexView); 213 | -------------------------------------------------------------------------------- /web/static/js/views/registrations/new.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router'; 4 | 5 | import { 6 | setDocumentTitle, 7 | renderErrorsFor, 8 | creditsText } from '../../utils'; 9 | import Actions from '../../actions/registrations'; 10 | 11 | class RegistrationsNewView extends React.Component { 12 | componentDidMount() { 13 | setDocumentTitle('Sign up'); 14 | } 15 | 16 | _handleSubmit(e) { 17 | e.preventDefault(); 18 | 19 | const { dispatch } = this.props; 20 | 21 | const data = { 22 | first_name: this.refs.firstName.value, 23 | email: this.refs.email.value, 24 | password: this.refs.password.value, 25 | }; 26 | 27 | dispatch(Actions.signUp(data)); 28 | } 29 | 30 | render() { 31 | const { errors } = this.props; 32 | 33 | return ( 34 |
    35 |
    36 |
    37 |
    38 |
    39 | {creditsText()} 40 |
    41 |
    42 |
    43 | 44 | {renderErrorsFor(errors, 'first_name')} 45 |
    46 |
    47 | 48 | {renderErrorsFor(errors, 'email')} 49 |
    50 |
    51 | 52 | {renderErrorsFor(errors, 'password')} 53 |
    54 | 55 |
    56 |
    57 | Sign in 58 |
    59 |
    60 | ); 61 | } 62 | } 63 | 64 | const mapStateToProps = (state) => ({ 65 | errors: state.registration.errors, 66 | }); 67 | 68 | export default connect(mapStateToProps)(RegistrationsNewView); 69 | -------------------------------------------------------------------------------- /web/static/js/views/reports/index.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { fetchData } from '../../actions/reports'; 4 | import { setDocumentTitle } from '../../utils'; 5 | import ReportContainer from '../../components/reports/container'; 6 | import RangeSelector from '../../components/reports/range_selector'; 7 | 8 | class ReportsIndeView extends React.Component { 9 | componentDidMount() { 10 | const { dispatch, channel, filter } = this.props; 11 | 12 | setDocumentTitle('Reports'); 13 | 14 | if (channel == null) return false; 15 | 16 | dispatch(fetchData(channel, filter.numberOfWeeks)); 17 | } 18 | 19 | componentWillReceiveProps(nextProps) { 20 | const { dispatch, channel } = this.props; 21 | const nextChannel = nextProps.channel; 22 | const nextFilter = nextProps.filter; 23 | 24 | if (channel == null && nextChannel != null) dispatch(fetchData(nextChannel, nextFilter.numberOfWeeks)); 25 | } 26 | 27 | render() { 28 | const { fetching, data, filter, dispatch, channel } = this.props; 29 | 30 | return ( 31 |
    32 |
    33 |
    34 |

    Summary report

    35 | 39 |
    40 | 2} /> 43 |
    44 |
    45 | ); 46 | } 47 | } 48 | 49 | const mapStateToProps = (state) => ( 50 | { ...state.reports, channel: state.session.channel } 51 | ); 52 | 53 | export default connect(mapStateToProps)(ReportsIndeView); 54 | -------------------------------------------------------------------------------- /web/static/js/views/sessions/new.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Link } from 'react-router'; 4 | 5 | import { 6 | setDocumentTitle, 7 | creditsText } from '../../utils'; 8 | import Actions from '../../actions/sessions'; 9 | 10 | class SessionsNewView extends React.Component { 11 | componentDidMount() { 12 | setDocumentTitle('Sign in'); 13 | } 14 | 15 | _handleSubmit(e) { 16 | e.preventDefault(); 17 | 18 | const { email, password } = this.refs; 19 | const { dispatch } = this.props; 20 | 21 | dispatch(Actions.signIn(email.value, password.value)); 22 | } 23 | 24 | _renderError() { 25 | let { error } = this.props; 26 | 27 | if (!error) return false; 28 | 29 | return ( 30 |
    31 | {error} 32 |
    33 | ); 34 | } 35 | 36 | render() { 37 | return ( 38 |
    39 |
    40 |
    41 |
    42 |
    43 | {creditsText()} 44 |
    45 |
    46 | {::this._renderError()} 47 |
    48 | 54 |
    55 |
    56 | 62 |
    63 | 64 |
    65 |
    66 | Create new account 67 |
    68 |
    69 | ); 70 | } 71 | } 72 | 73 | const mapStateToProps = (state) => ( 74 | state.session 75 | ); 76 | 77 | export default connect(mapStateToProps)(SessionsNewView); 78 | -------------------------------------------------------------------------------- /web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Phoenix Toggl 11 | 12 | 13 | "> 14 | "/> 15 | 16 | 17 | 18 |
    19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
    2 |

    <%= gettext "Welcome to %{name}", name: "Phoenix!" %>

    3 |

    A productive web framework that
    does not compromise speed and maintainability.

    4 |
    5 | 6 |
    7 |
    8 |

    Resources

    9 |
      10 |
    • 11 | Guides 12 |
    • 13 |
    • 14 | Docs 15 |
    • 16 |
    • 17 | Source 18 |
    • 19 |
    20 |
    21 | 22 |
    23 |

    Help

    24 |
      25 |
    • 26 | Mailing list 27 |
    • 28 |
    • 29 | #elixir-lang on freenode IRC 30 |
    • 31 |
    • 32 | @elixirphoenix 33 |
    • 34 |
    35 |
    36 |
    37 | -------------------------------------------------------------------------------- /web/views/current_user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.CurrentUserView do 2 | use PhoenixToggl.Web, :view 3 | 4 | def render("show.json", %{user: user}) do 5 | user 6 | end 7 | 8 | def render("error.json", _) do 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | if error = form.errors[field] do 13 | content_tag :span, translate_error(error), class: "help-block" 14 | end 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. On your own code and templates, 25 | # this could be written simply as: 26 | # 27 | # dngettext "errors", "1 file", "%{count} files", count 28 | # 29 | Gettext.dngettext(PhoenixToggl.Gettext, "errors", msg, msg, opts[:count], opts) 30 | end 31 | 32 | def translate_error(msg) do 33 | Gettext.dgettext(PhoenixToggl.Gettext, "errors", msg) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.ErrorView do 2 | use PhoenixToggl.Web, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Server internal error" 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render "500.html", assigns 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.LayoutView do 2 | use PhoenixToggl.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.PageView do 2 | use PhoenixToggl.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /web/views/registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.RegistrationView do 2 | use PhoenixToggl.Web, :view 3 | 4 | def render("error.json", %{changeset: changeset}) do 5 | errors = Enum.map(changeset.errors, fn {field, detail} -> 6 | %{} |> Map.put(field, render_detail(detail)) 7 | end) 8 | 9 | %{ 10 | errors: errors 11 | } 12 | end 13 | 14 | defp render_detail({message, values}) do 15 | Enum.reduce(values, message, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end) 16 | end 17 | 18 | defp render_detail(message) do 19 | message 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /web/views/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.SessionView do 2 | use PhoenixToggl.Web, :view 3 | 4 | def render("show.json", %{jwt: jwt, user: user}) do 5 | %{ 6 | jwt: jwt, 7 | user: user 8 | } 9 | end 10 | 11 | def render("error.json", _) do 12 | %{error: "Invalid email or password"} 13 | end 14 | 15 | def render("delete.json", _) do 16 | %{ok: true} 17 | end 18 | 19 | def render("forbidden.json", %{error: error}) do 20 | %{error: error} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /web/web.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixToggl.Web do 2 | @moduledoc """ 3 | A module that keeps using definitions for controllers, 4 | views and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use PhoenixToggl.Web, :controller 9 | use PhoenixToggl.Web, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. 17 | """ 18 | 19 | def model do 20 | quote do 21 | use Ecto.Schema 22 | use Timex.Ecto.Timestamps 23 | 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query, only: [from: 1, from: 2] 27 | end 28 | end 29 | 30 | def controller do 31 | quote do 32 | use Phoenix.Controller 33 | 34 | alias PhoenixToggl.Repo 35 | import Ecto 36 | import Ecto.Query, only: [from: 1, from: 2] 37 | 38 | import PhoenixToggl.Router.Helpers 39 | import PhoenixToggl.Gettext 40 | end 41 | end 42 | 43 | def view do 44 | quote do 45 | use Phoenix.View, root: "web/templates" 46 | 47 | # Import convenience functions from controllers 48 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 49 | 50 | # Use all HTML functionality (forms, tags, etc) 51 | use Phoenix.HTML 52 | 53 | import PhoenixToggl.Router.Helpers 54 | import PhoenixToggl.ErrorHelpers 55 | import PhoenixToggl.Gettext 56 | end 57 | end 58 | 59 | def router do 60 | quote do 61 | use Phoenix.Router 62 | end 63 | end 64 | 65 | def channel do 66 | quote do 67 | use Phoenix.Channel 68 | 69 | alias PhoenixToggl.Repo 70 | import Ecto 71 | import Ecto.Query, only: [from: 1, from: 2] 72 | import PhoenixToggl.Gettext 73 | end 74 | end 75 | 76 | @doc """ 77 | When used, dispatch to the appropriate controller/view/etc. 78 | """ 79 | defmacro __using__(which) when is_atom(which) do 80 | apply(__MODULE__, which, []) 81 | end 82 | end 83 | --------------------------------------------------------------------------------