Add tags and fix completed

This commit is contained in:
Chris Veleris 2023-11-15 14:12:13 +02:00
parent 3e80ff4cdd
commit 8ce2538f01
21 changed files with 173 additions and 73 deletions

3
app.rb
View file

@ -6,6 +6,7 @@ require './app/models/user'
require './app/models/area'
require './app/models/project'
require './app/models/task'
require './app/models/tag'
require './app/helpers/authentication_helper'
@ -91,5 +92,7 @@ get '/' do
end
get '/inbox' do
@tasks = current_user.tasks.incomplete.where(project_id: nil).order(:name)
erb :inbox
end

View file

@ -2,4 +2,7 @@ class Project < ActiveRecord::Base
belongs_to :user
belongs_to :area, optional: true
has_many :tasks, dependent: :destroy
scope :with_incomplete_tasks, -> { joins(:tasks).where(tasks: { completed: false }).distinct }
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { completed: true }).distinct }
end

4
app/models/tag.rb Normal file
View file

@ -0,0 +1,4 @@
class Tag < ActiveRecord::Base
belongs_to :user
has_and_belongs_to_many :tasks
end

View file

@ -1,6 +1,8 @@
class Task < ActiveRecord::Base
belongs_to :user
belongs_to :project, optional: true
has_and_belongs_to_many :tags
default_scope { where(completed: false) }
scope :complete, -> { where(completed: true) }
scope :incomplete, -> { where(completed: false) }
end

View file

@ -4,6 +4,7 @@ class User < ActiveRecord::Base
has_many :areas
has_many :projects
has_many :tasks
has_many :tags, dependent: :destroy
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true
end

View file

@ -1,6 +1,7 @@
class Sinatra::Application
get '/projects' do
@projects_with_tasks = current_user.projects.includes(:tasks).order(:name)
@projects_with_tasks = current_user.projects.with_incomplete_tasks.order(:name)
@projects_with_tasks_complete = current_user.projects.with_complete_tasks.order(:name)
erb :'projects/index'
end
@ -14,9 +15,10 @@ class Sinatra::Application
post '/project/create' do
project = current_user.projects.new(
name: params[:name],
name: params[:name],
description: params[:description],
area_id: params[:area_id].presence)
area_id: params[:area_id].presence
)
if project.save
redirect '/'

View file

@ -1,26 +1,45 @@
class Sinatra::Application
def update_task_tags(task, tags_json)
return if tags_json.blank?
begin
tag_names = JSON.parse(tags_json).map { |tag| tag['value'] }.uniq
tags = tag_names.map do |name|
current_user.tags.find_or_create_by(name: name)
end
task.tags = tags
rescue JSON::ParserError
puts "Failed to parse JSON for tags: #{tags_json}"
end
end
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 })
@tasks = current_user.tasks.incomplete.where('due_date <= ?', today.end_of_day).where(project: nil)
@projects_with_tasks = current_user.projects.with_incomplete_tasks
.where('tasks.due_date <= ?', 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')
@tasks = current_user.tasks.incomplete.where(due_date: Date.today..one_week_from_today, project: nil)
@projects_with_tasks = current_user.projects.with_incomplete_tasks.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')
@tasks = current_user.tasks.incomplete.where(due_date: nil, project: nil)
@projects_with_tasks = current_user.projects.with_incomplete_tasks.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')
if params[:status] == 'completed'
@tasks = current_user.tasks.complete.where(project: nil)
@projects_with_tasks = current_user.projects.with_complete_tasks.includes(:tasks).order('projects.name ASC')
else
@tasks = current_user.tasks.incomplete.where(project: nil)
@projects_with_tasks = current_user.projects.with_incomplete_tasks.includes(:tasks).order('projects.name ASC')
end
end
@tasks ||= []
@ -46,6 +65,7 @@ class Sinatra::Application
end
if task.save
update_task_tags(task, params[:tags])
redirect request.referrer || '/'
else
halt 400, 'There was a problem creating the task.'
@ -72,6 +92,7 @@ class Sinatra::Application
end
if task.update(task_attributes)
update_task_tags(task, params[:tags])
redirect request.referrer || '/'
else
halt 400, 'There was a problem updating the task.'
@ -80,7 +101,7 @@ class Sinatra::Application
patch '/task/:id/toggle_completion' do
content_type :json
task = current_user.tasks.find(params[:id])
task = current_user.tasks.find_by(id: params[:id])
if task
task.completed = !task.completed
if task.save

View file

@ -7,10 +7,9 @@
<%= partial :'tasks/_form', locals: { task: Task.new } %>
</div>
</div>
<div class="mx-3 my-2">
<% standalone_tasks = current_user.tasks.where(project_id: nil) %>
<% if standalone_tasks.any? %>
<% standalone_tasks.each do |task| %>
<div class="mx-3 mt-4 mb-2">
<% if @tasks %>
<% @tasks.each do |task| %>
<div id="edit_task_form_<%= task.id %>" class="d-none">
<%= partial :'tasks/_form', locals: { task: task } %>
</div>

View file

@ -7,22 +7,24 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet">
<link rel="stylesheet" href="/css/app.css">
</head>
<body class="container-fluid bg-light">
<body class="container-fluid" style="background: #fcfcfc">
<div class="row flex-nowrap">
<% if current_user %>
<%= partial :'sidebar' %>
<%= partial :'projects/_new_project_modal' %>
<div class="px-md-4 pt-4 mb-3 main-content col-md-9 col-lg-10" style="background: #fbfbfb">
<div class="px-md-4 pt-4 mb-3 main-content col-md-9 col-lg-10">
<%= yield %>
</div>
<% else %>
<div class="px-md-4 pt-4 mb-3 col-md-12" style="background: #fbfbfb">
<div class="px-md-4 pt-4 mb-3 col-md-12">
<%= yield %>
</div>
<% end %>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script> </body>
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify@latest/dist/tagify.min.js"></script>
<script src="/js/app.js"></script>
</html>

View file

@ -11,7 +11,7 @@
<% if @projects_with_tasks.any? %>
<% @projects_with_tasks.each do |project| %>
<% if project.tasks.any? %>
<h5 class="mt-4 ms-4 mb-2">
<h5 class="mt-4 mb-2">
<div class="px-2 py-1 mb-1 border border-dark d-inline-block"><%= project.name.upcase %></div>
</h5>
<% project.tasks.each do |task| %>

View file

@ -24,7 +24,7 @@
<%= @project.description %>
</div>
<% end %>
<div class="bg-light py-2 px-3 mx-3 d-flex align-items-center border" data-bs-toggle="collapse" data-bs-target="#newTaskForm" aria-expanded="false" aria-controls="newTaskForm" style="cursor: pointer;">
<div class="bg-light py-2 mb-4 px-3 mx-3 d-flex align-items-center border" data-bs-toggle="collapse" data-bs-target="#newTaskForm" aria-expanded="false" aria-controls="newTaskForm" style="cursor: pointer;">
<i class="fs-4 bi bi-plus-circle-fill text-primary me-2"></i> <span class="fs-6">New task</span>
</div>
<div class="collapse" id="newTaskForm">

View file

@ -24,13 +24,13 @@
</a>
</li>
<li>
<a href="/tasks?due_date=anytime" class="<%= nav_link('/tasks', 'due_date' => 'anytime') %>">
<i class="bi bi-sun-fill me-1"></i> Anytime
<a href="/tasks?due_date=never" class="<%= nav_link('/tasks', 'due_date' => 'never') %>">
<i class="bi bi-moon-stars-fill me-1"></i> Someday
</a>
</li>
<li>
<a href="/tasks?due_date=never" class="<%= nav_link('/tasks', 'due_date' => 'never') %>">
<i class="bi bi-moon-stars-fill me-1"></i> Someday
<a href="/tasks?status=completed" class="<%= nav_link('/tasks', 'status' => 'completed') %>">
<i class="bi bi-check-circle me-1"></i> Completed
</a>
</li>
<li class="border-top my-3"></li>

View file

@ -1,6 +1,5 @@
<% action_url = task.new_record? ? '/task/create' : "/task/#{task.id}" %>
<% method = task.new_record? ? 'post' : 'patch' %>
<form action="<%= action_url %>" method="post" class="">
<% unless task.new_record? %>
<input type="hidden" name="_method" value="<%= method %>">
@ -19,13 +18,13 @@
<option value="">No Project</option>
<% current_user.projects.each do |project| %>
<option value="<%= project.id %>" <%= 'selected' if task.project_id == project.id %>><%= project.name %></option>
<% end %>
</select>
</div>
<div class="col-md-4">
<label for="task_priority" class="form-label">Priority:</label>
<select id="task_priority" name="priority" class="form-select">
<option value="Low" <%= 'selected' if task.priority == 'Low' %>>Low</option>
<% end %>
</select>
</div>
<div class="col-md-4">
<label for="task_priority" class="form-label">Priority:</label>
<select id="task_priority" name="priority" class="form-select">
<option value="Low" <%= 'selected' if task.priority == 'Low' %>>Low</option>
<option value="Medium" <%= 'selected' if task.priority == 'Medium' %>>Medium</option>
<option value="High" <%= 'selected' if task.priority == 'High' %>>High</option>
</select>
@ -33,32 +32,36 @@
<div class="col-md-4">
<label for="task_due_date" class="form-label">Due Date:</label>
<input type="date" id="task_due_date" name="due_date" value="<%= task.due_date.strftime('%Y-%m-%d') if task.due_date %>" class="form-control">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<%= task.new_record? ? 'Create Task' : 'Update Task' %>
</button>
<% unless task.new_record? %>
<button type="button" class="btn btn-outline-danger" onclick="deleteTask('<%= task.id %>')">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<label for="task_tags" class="form-label">Tags:</label>
<input name="tags" id="task_tags" class="form-control" value="<%= task.tags&.map(&:name)&.join(',') %>">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<%= task.new_record? ? 'Create Task' : 'Update Task' %>
</button>
<% unless task.new_record? %>
<button type="button" class="btn btn-outline-danger" onclick="deleteTask('<%= task.id %>')">
<i class="bi bi-trash"></i>
</button>
<% end %>
</div>
</fieldset>
</form>
<% if !task.new_record? %>
<form id="delete_task_<%= task.id %>" action="/task/<%= task.id %>" method="post" class="d-none">
<input type="hidden" name="_method" value="delete">
</form>
<% end %>
</div>
</fieldset>
</form>
<% if !task.new_record? %>
<form id="delete_task_<%= task.id %>" action="/task/<%= task.id %>" method="post" class="d-none">
<input type="hidden" name="_method" value="delete">
</form>
<% end %>
<script>
function deleteTask(taskId) {
if (confirm('Are you sure you want to delete this task?')) {
var form = document.getElementById('delete_task_' + taskId);
form.submit();
}
}
</script>
<script>
function deleteTask(taskId) {
if (confirm('Are you sure you want to delete this task?')) {
var form = document.getElementById('delete_task_' + taskId);
form.submit();
}
}
</script>

View file

@ -4,6 +4,6 @@
<h2 class="mb-5"><i class="bi bi-calendar3 ms-3 me-2"></i> Upcoming</h2>
<% elsif params[:due_date] == 'never' %>
<h2 class="mb-5"><i class="bi bi-moon-stars-fill ms-3 me-2"></i> Someday</h2>
<% elsif params[:due_date] == 'anytime' %>
<h2 class="mb-5"><i class="bi bi-sun-fill ms-3 me-2"></i> Anytime</h2>
<% elsif params[:status] == 'completed' %>
<h2 class="mb-5"><i class="bi bi-check-circle ms-3 me-2"></i> Completed</h2>
<% end %>

View file

@ -6,6 +6,13 @@
<div class="col-md-5">
<%= task.name %>
</div>
<div class="col-md-3">
<% task.tags.each do |tag| %>
<span class="badge bg-primary-subtle text-primary rounded">
<%= tag.name %>
</span>
<% end %>
</div>
<div class="col-md-3">
<% if task.due_date %>
<% if task.due_date.to_date == Date.today %>

View file

@ -8,7 +8,7 @@
<%= partial :'tasks/_form', locals: { task: Task.new } %>
</div>
</div>
<div class="mx-3 my-2">
<div class="mx-3 mt-4 mb-4">
<% if @tasks.any? %>
<% @tasks.each do |task| %>
<div id="edit_task_form_<%= task.id %>" class="d-none">

View file

@ -0,0 +1,10 @@
class AddTags < ActiveRecord::Migration[7.1]
def change
create_table :tags do |t|
t.string :name
t.references :user, null: false, foreign_key: { on_delete: :cascade }
t.timestamps
end
end
end

View file

@ -0,0 +1,8 @@
class CreateTasksTags < ActiveRecord::Migration[7.1]
def change
create_table :tasks_tags, id: false do |t|
t.belongs_to :task, null: false, foreign_key: true
t.belongs_to :tag, null: false, foreign_key: true
end
end
end

View file

@ -0,0 +1,5 @@
class RenameTasksTagsToTagsTasks < ActiveRecord::Migration[7.1]
def change
rename_table :tasks_tags, :tags_tasks
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2023_11_10_163101) do
ActiveRecord::Schema[7.1].define(version: 2023_11_15_092055) do
create_table "areas", force: :cascade do |t|
t.string "name"
t.integer "user_id", null: false
@ -30,6 +30,21 @@ ActiveRecord::Schema[7.1].define(version: 2023_11_10_163101) do
t.index ["user_id"], name: "index_projects_on_user_id"
end
create_table "tags", force: :cascade do |t|
t.string "name"
t.integer "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_tags_on_user_id"
end
create_table "tags_tasks", id: false, force: :cascade do |t|
t.integer "task_id", null: false
t.integer "tag_id", null: false
t.index ["tag_id"], name: "index_tags_tasks_on_tag_id"
t.index ["task_id"], name: "index_tags_tasks_on_task_id"
end
create_table "tasks", force: :cascade do |t|
t.string "name"
t.string "priority"
@ -56,6 +71,9 @@ ActiveRecord::Schema[7.1].define(version: 2023_11_10_163101) do
add_foreign_key "areas", "users"
add_foreign_key "projects", "areas", on_delete: :cascade
add_foreign_key "projects", "users"
add_foreign_key "tags", "users", on_delete: :cascade
add_foreign_key "tags_tasks", "tags"
add_foreign_key "tags_tasks", "tasks"
add_foreign_key "tasks", "projects", on_delete: :cascade
add_foreign_key "tasks", "users"
end

View file

@ -1,5 +1,6 @@
document.addEventListener("DOMContentLoaded", function () {
attachEventListeners();
new Tagify(document.getElementById('task_tags'));
});
function attachEventListeners() {
@ -17,10 +18,13 @@ function attachCollapseListeners() {
}
function toggleFolderIcon(collapseElement, isOpening) {
const closedFolderIcon = collapseElement.previousElementSibling.querySelector('.bi-folder');
const openFolderIcon = collapseElement.previousElementSibling.querySelector('.bi-folder2-open');
closedFolderIcon.classList.toggle('d-none', isOpening);
openFolderIcon.classList.toggle('d-none', !isOpening);
const closedFolderIcon = collapseElement.previousElementSibling?.querySelector('.bi-folder');
const openFolderIcon = collapseElement.previousElementSibling?.querySelector('.bi-folder2-open');
if (closedFolderIcon && openFolderIcon) {
closedFolderIcon.classList.toggle('d-none', isOpening);
openFolderIcon.classList.toggle('d-none', !isOpening);
}
}
function attachTaskClickListeners() {
@ -40,10 +44,15 @@ function openEditTaskModal(taskId) {
return;
}
const formHtml = formContainer.innerHTML;
document.getElementById('editTaskFormContainer').innerHTML = formHtml;
const editTaskFormContainer = document.getElementById('editTaskFormContainer');
editTaskFormContainer.innerHTML = formHtml;
new Tagify(editTaskFormContainer.querySelector('#task_tags'));
new bootstrap.Modal(document.getElementById('editTaskModal')).show();
}
function attachProjectModalListeners() {
document.querySelectorAll('[data-bs-toggle="modal"][data-project-id]').forEach(button => {
button.addEventListener('click', () => openProjectModalForEdit(button.getAttribute('data-project-id')));
@ -152,14 +161,17 @@ function updateTaskCompletionStatus(taskId, data) {
taskIcon.classList.remove('bi-circle', 'text-warning', 'text-danger');
taskIcon.classList.add('bi-check-circle-fill', 'text-success');
taskDiv.classList.add('opacity-50');
} else {
taskIcon.classList.remove('bi-check-circle-fill', 'text-success');
taskIcon.classList.add('bi-circle');
taskDiv.classList.remove('opacity-50');
applyPriorityColor(taskIcon, data.priority);
}
setTimeout(() => taskDiv.remove(), 200);
}
function applyPriorityColor(taskIcon, priority) {
taskIcon.classList.remove('text-warning', 'text-danger', 'text-secondary');
switch (priority) {