diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..ab96490e Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..23da96ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal +certs/ \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..c40d733d --- /dev/null +++ b/Gemfile @@ -0,0 +1,20 @@ +source 'https://rubygems.org' + +gem 'sinatra' +gem 'puma' +gem 'rake' + +# DB +gem 'sinatra-activerecord' +gem 'sqlite3' + +# Authentication +gem 'bcrypt' + +# Other +gem 'rerun' +gem 'byebug' + +# Development +gem 'faker' +gem 'rubocop' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..c4af85b2 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,109 @@ +GEM + remote: https://rubygems.org/ + specs: + activemodel (7.1.1) + activesupport (= 7.1.1) + activerecord (7.1.1) + activemodel (= 7.1.1) + activesupport (= 7.1.1) + timeout (>= 0.4.0) + activesupport (7.1.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + ast (2.4.2) + base64 (0.2.0) + bcrypt (3.1.19) + bigdecimal (3.1.4) + byebug (11.1.3) + concurrent-ruby (1.2.2) + connection_pool (2.4.1) + drb (2.2.0) + ruby2_keywords + faker (3.2.2) + i18n (>= 1.8.11, < 2) + ffi (1.16.3) + i18n (1.14.1) + concurrent-ruby (~> 1.0) + json (2.6.3) + language_server-protocol (3.17.0.3) + listen (3.8.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + minitest (5.20.0) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) + mutex_m (0.2.0) + nio4r (2.5.9) + parallel (1.23.0) + parser (3.2.2.4) + ast (~> 2.4.1) + racc + puma (6.4.0) + nio4r (~> 2.0) + racc (1.7.3) + rack (2.2.8) + rack-protection (3.1.0) + rack (~> 2.2, >= 2.2.4) + rainbow (3.1.1) + rake (13.1.0) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + regexp_parser (2.8.2) + rerun (0.14.0) + listen (~> 3.0) + rexml (3.2.6) + rubocop (1.57.2) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.2.2.4) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.30.0) + parser (>= 3.2.1.0) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + sinatra (3.1.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.1.0) + tilt (~> 2.0) + sinatra-activerecord (2.0.27) + activerecord (>= 4.1) + sinatra (>= 1.0) + sqlite3 (1.6.8-arm64-darwin) + tilt (2.3.0) + timeout (0.4.1) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + +PLATFORMS + arm64-darwin-22 + +DEPENDENCIES + bcrypt + byebug + faker + puma + rake + rerun + rubocop + sinatra + sinatra-activerecord + sqlite3 + +BUNDLED WITH + 2.4.21 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e3bb13da --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [2023] [Chris Veleris] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..46758f64 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# tu | du | di + +`tu|du|di` is a task and project management web application built with Sinatra. It allows users to efficiently manage their tasks and projects, categorize them into different areas, and track due dates. `tu|du|di` is designed to be intuitive and easy to use, providing a seamless experience for personal productivity. + +## Features + +- **Task Management**: Create, update, and delete tasks. Mark tasks as completed and view them by different filters (Today, Upcoming, Someday). +- **Project Tracking**: Organize tasks into projects. Each project can contain multiple tasks. +- **Area Categorization**: Group projects into areas for better organization and focus. +- **Due Date Tracking**: Set due dates for tasks and view them based on due date categories. +- **Responsive Design (in progress) **: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones. + +## Getting Started + +### Prerequisites + +Before you begin, ensure you have met the following requirements: +- Ruby (version 3.2.2 or higher) +- Sinatra +- SQLite3 +- Puma + +### Installation + +To install `tu|du|di`, follow these steps: + +1. Clone the repository: + ```bash + git clone https://github.com/chrisvel/tu-du-di.git + ``` +2. Navigate to the project directory: + ```bash + cd tu-du-di + ``` +3. Install the required gems: + ```bash + bundle install + ``` + +#### SSL setup + +1. Create and enter the directory: + ```bash + mkdir certs + ``` + +2. Navigate to the certs directory: + ```bash + cd certs + ``` + +2. Create the key and cert: + ```bash + openssl genrsa -out server.key 2048 + openssl req -new -x509 -key server.key -out server.crt -days 365 + ``` + +### Usage + +To start the application, run the following command in your terminal: + +```bash +puma -C app/config/puma.rb +``` + +Open your browser and navigate to `http://localhost:9292` to access the application. + +## Contributing + +Contributions to `tu|du|di` are welcome. To contribute: + +1. Fork the repository. +2. Create a new branch (`git checkout -b feature/AmazingFeature`). +3. Make your changes. +4. Commit your changes (`git commit -m 'Add some AmazingFeature'`). +5. Push to the branch (`git push origin feature/AmazingFeature`). +6. Open a pull request. + +## License + +This project is licensed under the [MIT License](LICENSE). + +## Contact + +If you have any questions or comments about `tu|du|di`, please feel free to [open an issue](https://github.com/chrisvel/tu-du-di/issues) or contact the developer directly. + +--- + +README created by [Chris Veleris](https://github.com/chrisvel) for `tu|du|di`. diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..7be69c7d --- /dev/null +++ b/Rakefile @@ -0,0 +1,12 @@ +require 'irb' + +require 'sinatra/activerecord' +require 'sinatra/activerecord/rake' + +require './app' + +desc 'Start an interactive console' +task :console do + ARGV.clear + IRB.start +end \ No newline at end of file diff --git a/app.rb b/app.rb new file mode 100644 index 00000000..079217bc --- /dev/null +++ b/app.rb @@ -0,0 +1,90 @@ +require 'sinatra' +require 'sinatra/activerecord' +require 'securerandom' + +require './app/models/user' +require './app/models/area' +require './app/models/project' +require './app/models/task' + +require './app/helpers/authentication_helper' + +require './app/routes/authentication_routes' +require './app/routes/tasks_routes' +require './app/routes/projects_routes' +require './app/routes/areas_routes' + +helpers AuthenticationHelper + +use Rack::MethodOverride + +set :database_file, './app/config/database.yml' +set :views, proc { File.join(root, 'app/views') } +set :public_folder, 'public' + +configure do + enable :sessions + set :sessions, httponly: true, secure: production?, expire_after: 2_592_000 + set :session_secret, ENV.fetch('SESSION_SECRET') { SecureRandom.hex(64) } + set :session_secret, + '740cca863278d6cbacb64dbdd41cfdb1598e8208ce9b9d29b0a1e7c1e1367ca1241d8048849ee88784731d43879c94f5b9f0a639135828d590a447acb2d98e1c' +end + +use Rack::Protection + +before do + require_login +end + +helpers do + def current_path + request.path_info + end + + def partial(page, options = {}) + erb page, options.merge!(layout: false) + end + + def priority_class(task) + return 'text-success' if task.completed + + case task.priority + when 'Medium' then 'text-warning' + when 'High' then 'text-danger' + else 'text-secondary' + end + end + + def nav_link(path, query_params = {}, project_id = nil) + current_uri = request.path_info + current_query = request.query_string + + current_params = Rack::Utils.parse_nested_query(current_query) + + is_project_page = current_uri.include?('/project/') && path.include?('/project/') + + is_active = if is_project_page + current_uri == path && (!project_id || current_uri.end_with?("/#{project_id}")) + elsif !query_params.empty? + current_uri == path && query_params.all? { |k, v| current_params[k] == v } + else + current_uri == path && current_params.empty? + end + + classes = 'nav-link py-1 px-3' + classes += ' active bg-dark' if is_active + classes += ' link-dark' unless is_active + + classes + end +end + +get '/' do + redirect '/inbox' if logged_in? + + erb :inbox +end + +get '/inbox' do + erb :inbox +end \ No newline at end of file diff --git a/app/config/database.yml b/app/config/database.yml new file mode 100644 index 00000000..26be58ca --- /dev/null +++ b/app/config/database.yml @@ -0,0 +1,17 @@ +# config/database.yml +default: &default + adapter: sqlite3 + pool: 5 + timeout: 5000 + +development: + <<: *default + database: db/development.sqlite3 + +test: + <<: *default + database: db/test.sqlite3 + +production: + <<: *default + database: db/production.sqlite3 diff --git a/app/config/puma.rb b/app/config/puma.rb new file mode 100644 index 00000000..28f2b92f --- /dev/null +++ b/app/config/puma.rb @@ -0,0 +1,5 @@ +ssl_bind '0.0.0.0', '9292', { + key: 'certs/server.key', + cert: 'certs/server.crt', + verify_mode: 'none' +} diff --git a/app/helpers/authentication_helper.rb b/app/helpers/authentication_helper.rb new file mode 100644 index 00000000..c3f558be --- /dev/null +++ b/app/helpers/authentication_helper.rb @@ -0,0 +1,15 @@ +module AuthenticationHelper + def logged_in? + !!session[:user_id] + end + + def current_user + @current_user ||= User.find(session[:user_id]) if session[:user_id] + end + + def require_login + return if ['/login', '/logout', '/signup'].include? request.path + + redirect '/login' unless logged_in? + end +end diff --git a/app/models/area.rb b/app/models/area.rb new file mode 100644 index 00000000..a5cc3c8f --- /dev/null +++ b/app/models/area.rb @@ -0,0 +1,4 @@ +class Area < ActiveRecord::Base + belongs_to :user + has_many :projects, dependent: :destroy +end diff --git a/app/models/project.rb b/app/models/project.rb new file mode 100644 index 00000000..e79f5d77 --- /dev/null +++ b/app/models/project.rb @@ -0,0 +1,5 @@ +class Project < ActiveRecord::Base + belongs_to :user + belongs_to :area, optional: true + has_many :tasks, dependent: :destroy +end diff --git a/app/models/task.rb b/app/models/task.rb new file mode 100644 index 00000000..efc95534 --- /dev/null +++ b/app/models/task.rb @@ -0,0 +1,6 @@ +class Task < ActiveRecord::Base + belongs_to :user + belongs_to :project, optional: true + + default_scope { where(completed: false) } +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 00000000..bfb6931e --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,9 @@ +class User < ActiveRecord::Base + has_secure_password + + has_many :areas + has_many :projects + has_many :tasks + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true +end diff --git a/app/routes/areas_routes.rb b/app/routes/areas_routes.rb new file mode 100644 index 00000000..e13370ac --- /dev/null +++ b/app/routes/areas_routes.rb @@ -0,0 +1,42 @@ +class Sinatra::Application + post '/areas/create' do + area = current_user.areas.create(name: params[:name]) + + if area.persisted? + redirect '/' + else + @errors = 'There was a problem creating the area.' + redirect '/' + end + end + + patch '/areas/:id' do + area = current_user.areas.find_by(id: params[:id]) + + if area + area.name = params[:name] + + if area.save + redirect '/' + else + @errors = 'There was a problem updating the area.' + erb :some_template + end + else + status 404 + "Area not found or doesn't belong to the current user." + end + end + + delete '/area/:id' do + area = current_user.areas.find_by(id: params[:id]) + + if area + area.destroy + redirect '/' + else + status 404 + @errors = 'Area not found or not owned by the current user.' + end + end +end diff --git a/app/routes/authentication_routes.rb b/app/routes/authentication_routes.rb new file mode 100644 index 00000000..f4855cae --- /dev/null +++ b/app/routes/authentication_routes.rb @@ -0,0 +1,22 @@ +class Sinatra::Application + get '/login' do + erb :login + end + + post '/login' do + @user = User.find_by(email: params[:email]) + if @user&.authenticate(params[:password]) + session[:user_id] = @user.id + redirect '/' + else + logger.warn "Invalid credentials for user with email #{params[:email]}" + @errors = ['Invalid credentials'] + erb :login + end + end + + get '/logout' do + session.clear + redirect '/login' + end +end \ No newline at end of file diff --git a/app/routes/projects_routes.rb b/app/routes/projects_routes.rb new file mode 100644 index 00000000..d2c4f7ab --- /dev/null +++ b/app/routes/projects_routes.rb @@ -0,0 +1,57 @@ +class Sinatra::Application + get '/projects' do + @projects_with_tasks = current_user.projects.includes(:tasks).order(:name) + + erb :'projects/index' + end + + get '/project/:id' do + @project = current_user.projects.includes(:tasks).find_by(id: params[:id]) + halt 404, 'Project not found' unless @project + + erb :'projects/show' + end + + post '/project/create' do + project = current_user.projects.new(name: params[:name], area_id: params[:area_id].presence) + + if project.save + redirect '/' + else + @errors = 'There was a problem creating the project.' + redirect '/' + end + end + + patch '/project/:id' do + project = current_user.projects.find_by(id: params[:id]) + + if project + project.name = params[:name] + project.description = params[:description] + project.area_id = params[:area_id].presence + + if project.save + redirect "/project/#{project.id}" + else + @errors = 'There was a problem updating the project.' + erb :edit_project + end + else + status 404 + "Project not found or doesn't belong to the current user." + end + end + + delete '/project/:id' do + project = current_user.projects.find_by(id: params[:id]) + + if project + project.destroy + redirect '/projects' + else + status 404 + "Project not found or doesn't belong to the current user." + end + end +end diff --git a/app/routes/tasks_routes.rb b/app/routes/tasks_routes.rb new file mode 100644 index 00000000..933f0d66 --- /dev/null +++ b/app/routes/tasks_routes.rb @@ -0,0 +1,108 @@ +class Sinatra::Application + get '/tasks' do + case params[:due_date] + when 'today' + today = Date.today + @tasks = current_user.tasks.where(due_date: today.beginning_of_day..today.end_of_day, project: nil) + @projects_with_tasks = current_user.projects + .joins(:tasks) + .where(tasks: { due_date: today.beginning_of_day..today.end_of_day }) + .distinct.order('projects.name ASC') + + when 'upcoming' + one_week_from_today = Date.today + 7.days + @tasks = current_user.tasks.where(due_date: Date.today..one_week_from_today, project: nil) + @projects_with_tasks = current_user.projects.includes(:tasks).where(tasks: { due_date: Date.today..one_week_from_today }).order('projects.name ASC') + + when 'never' + @tasks = current_user.tasks.where(due_date: nil, project: nil) + @projects_with_tasks = current_user.projects.includes(:tasks).where(tasks: { due_date: nil }).order('projects.name ASC') + + else + @tasks = current_user.tasks.where(project: nil) + @projects_with_tasks = current_user.projects.includes(:tasks).order('projects.name ASC') + end + + @tasks ||= [] + @projects_with_tasks ||= [] + + erb :'tasks/index' + end + + post '/task/create' do + task_attributes = { + name: params[:name], + priority: params[:priority], + due_date: params[:due_date], + user_id: current_user.id + } + + if params[:project_id].empty? + task = current_user.tasks.build(task_attributes) + else + project = current_user.projects.find_by(id: params[:project_id]) + halt 400, 'Invalid project.' unless project + task = project.tasks.build(task_attributes) + end + + if task.save + redirect request.referrer || '/' + else + halt 400, 'There was a problem creating the task.' + end + end + + patch '/task/:id' do + task = current_user.tasks.find_by(id: params[:id]) + + halt 404, 'Task not found.' unless task + + task_attributes = { + name: params[:name], + priority: params[:priority], + due_date: params[:due_date] + } + + if params[:project_id] && !params[:project_id].empty? + project = current_user.projects.find_by(id: params[:project_id]) + halt 400, 'Invalid project.' unless project + task.project = project + else + task.project = nil + end + + if task.update(task_attributes) + redirect '/' + else + halt 400, 'There was a problem updating the task.' + end + end + + patch '/task/:id/toggle_completion' do + content_type :json + task = current_user.tasks.find(params[:id]) + if task + task.completed = !task.completed + if task.save + task.to_json + else + status 422 + { error: 'Unable to update task' }.to_json + end + else + status 400 + { error: 'Task not found' }.to_json + end + end + + delete '/task/:id' do + task = current_user.tasks.find_by(id: params[:id]) + halt 404, 'Task not found.' unless task + + if task.destroy + redirect '/' + else + halt 400, 'There was a problem deleting the task.' + end + end +end diff --git a/app/views/areas/_edit_area_modal.erb b/app/views/areas/_edit_area_modal.erb new file mode 100644 index 00000000..dcc29d35 --- /dev/null +++ b/app/views/areas/_edit_area_modal.erb @@ -0,0 +1,19 @@ +
diff --git a/app/views/areas/_form.erb b/app/views/areas/_form.erb new file mode 100644 index 00000000..886b8259 --- /dev/null +++ b/app/views/areas/_form.erb @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/app/views/areas/_new_area_modal.erb b/app/views/areas/_new_area_modal.erb new file mode 100644 index 00000000..85609e32 --- /dev/null +++ b/app/views/areas/_new_area_modal.erb @@ -0,0 +1,19 @@ + diff --git a/app/views/inbox.erb b/app/views/inbox.erb new file mode 100644 index 00000000..35b7e9e9 --- /dev/null +++ b/app/views/inbox.erb @@ -0,0 +1,21 @@ +Please log in to view your dashboard.
+<% end %> diff --git a/app/views/sidebar.erb b/app/views/sidebar.erb new file mode 100644 index 00000000..d6f882fd --- /dev/null +++ b/app/views/sidebar.erb @@ -0,0 +1,103 @@ + +<%= partial :'projects/_new_project_modal' %> +<%= partial :'areas/_new_area_modal' %> +<% current_user.areas.each do |area| %> + <%= partial :'areas/_edit_area_modal', locals: { area: area } %> +<% end %> diff --git a/app/views/tasks/_edit_task_modal.erb b/app/views/tasks/_edit_task_modal.erb new file mode 100644 index 00000000..c371f7bc --- /dev/null +++ b/app/views/tasks/_edit_task_modal.erb @@ -0,0 +1,13 @@ + diff --git a/app/views/tasks/_form.erb b/app/views/tasks/_form.erb new file mode 100644 index 00000000..2da84c69 --- /dev/null +++ b/app/views/tasks/_form.erb @@ -0,0 +1,64 @@ +<% action_url = task.new_record? ? '/task/create' : "/task/#{task.id}" %> +<% method = task.new_record? ? 'post' : 'patch' %> + + + +<% if !task.new_record? %> + +<% end %> + + diff --git a/app/views/tasks/_header.erb b/app/views/tasks/_header.erb new file mode 100644 index 00000000..d55d2149 --- /dev/null +++ b/app/views/tasks/_header.erb @@ -0,0 +1,9 @@ +<% if params[:due_date] == 'today' %> +