Merge branch 'main' into feature/the-eisenhower-matrix

This commit is contained in:
sosokker 2023-11-11 21:57:56 +07:00
commit a9859c0789
38 changed files with 1847 additions and 584 deletions

View File

@ -6,4 +6,6 @@
TurTask is a task and project management tool that incorporates gamification elements. TurTask is a task and project management tool that incorporates gamification elements.
[Wiki Repository](https://github.com/TurTaskProject/TurTaskWiki) [Wiki Page](https://github.com/TurTaskProject/TurTaskWeb/wiki)
[Project Board](https://github.com/orgs/TurTaskProject/projects/1)

View File

@ -7,8 +7,8 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from tasks.utils import get_service from tasks.utils import get_service
from tasks.models import Task from tasks.models import Todo, RecurrenceTask
from tasks.serializers import TaskUpdateSerializer from tasks.serializers import TodoUpdateSerializer, RecurrenceTaskUpdateSerializer
class GoogleCalendarEventViewset(viewsets.ViewSet): class GoogleCalendarEventViewset(viewsets.ViewSet):
@ -17,28 +17,34 @@ class GoogleCalendarEventViewset(viewsets.ViewSet):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__() super().__init__()
self.current_time = datetime.now(tz=timezone.utc).isoformat() self.current_time = datetime.now(tz=timezone.utc).isoformat()
self.event_fields = 'items(id,summary,description,created,updated,start,end)' self.event_fields = 'items(id,summary,description,created,recurringEventId,updated,start,end)'
def _validate_serializer(self, serializer): def _validate_serializer(self, serializer):
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
return Response("Task Sync Successfully", status=200) return Response("Validate Successfully", status=200)
return Response(serializer.errors, status=400) return Response(serializer.errors, status=400)
def post(self, request): def post(self, request):
service = get_service(request) service = get_service(request)
events = service.events().list(calendarId='primary', fields=self.event_fields).execute() events = service.events().list(calendarId='primary', fields=self.event_fields).execute()
for event in events.get('items', []): for event in events.get('items', []):
if event.get('recurringEventId'):
continue
event['start_datetime'] = event.get('start').get('dateTime')
event['end_datetime'] = event.get('end').get('dateTime')
event.pop('start')
event.pop('end')
try: try:
task = Task.objects.get(google_calendar_id=event['id']) task = Todo.objects.get(google_calendar_id=event['id'])
serializer = TaskUpdateSerializer(instance=task, data=event) serializer = TodoUpdateSerializer(instance=task, data=event)
return self._validate_serializer(serializer) return self._validate_serializer(serializer)
except Task.DoesNotExist: except Todo.DoesNotExist:
serializer = TaskUpdateSerializer(data=event, user=request.user) serializer = TodoUpdateSerializer(data=event, user=request.user)
return self._validate_serializer(serializer) return self._validate_serializer(serializer)
def list(self, request, days=7): def list(self, request, days=7):
max_time = (datetime.now(tz=timezone.utc) + timedelta(days=3)).isoformat() max_time = (datetime.now(tz=timezone.utc) + timedelta(days=days)).isoformat()
service = get_service(request) service = get_service(request)
events = [] events = []
@ -49,11 +55,11 @@ class GoogleCalendarEventViewset(viewsets.ViewSet):
calendarId='primary', calendarId='primary',
timeMin=self.current_time, timeMin=self.current_time,
timeMax=max_time, timeMax=max_time,
maxResults=20, maxResults=200,
singleEvents=True, singleEvents=True,
orderBy='startTime', orderBy='startTime',
pageToken=next_page_token, pageToken=next_page_token,
fields='items(id,summary,description,created,updated,start,end)', fields='items(id,summary,description,created,recurringEventId,updated,start,end)',
) )
page_results = query.execute() page_results = query.execute()

View File

@ -4,3 +4,6 @@ from django.apps import AppConfig
class TasksConfig(AppConfig): class TasksConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'tasks' name = 'tasks'
def ready(self):
import tasks.signals

View File

@ -0,0 +1,47 @@
# Generated by Django 4.2.6 on 2023-11-06 15:01
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('tasks', '0009_alter_task_options_task_importance_and_more'),
]
operations = [
migrations.CreateModel(
name='Todo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.TextField()),
('notes', models.TextField(default='')),
('importance', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')], default=1)),
('difficulty', models.PositiveSmallIntegerField(choices=[(1, 'Easy'), (2, 'Normal'), (3, 'Hard'), (4, 'Very Hard'), (5, 'Devil')], default=1)),
('challenge', models.BooleanField(default=False)),
('fromSystem', models.BooleanField(default=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('last_update', models.DateTimeField(auto_now=True)),
('google_calendar_id', models.CharField(blank=True, max_length=255, null=True)),
('start_event', models.DateTimeField(null=True)),
('end_event', models.DateTimeField(null=True)),
('priority', models.PositiveSmallIntegerField(choices=[(1, 'Important & Urgent'), (2, 'Important & Not Urgent'), (3, 'Not Important & Urgent'), (4, 'Not Important & Not Urgent')], default=4)),
('tags', models.ManyToManyField(blank=True, to='tasks.tag')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='subtask',
name='parent_task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tasks.todo'),
),
migrations.DeleteModel(
name='Task',
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.6 on 2023-11-06 16:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('tasks', '0010_todo_alter_subtask_parent_task_delete_task'),
]
operations = [
migrations.CreateModel(
name='RecurrenceTask',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.TextField()),
('notes', models.TextField(default='')),
('importance', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')], default=1)),
('difficulty', models.PositiveSmallIntegerField(choices=[(1, 'Easy'), (2, 'Normal'), (3, 'Hard'), (4, 'Very Hard'), (5, 'Devil')], default=1)),
('challenge', models.BooleanField(default=False)),
('fromSystem', models.BooleanField(default=False)),
('creation_date', models.DateTimeField(auto_now_add=True)),
('last_update', models.DateTimeField(auto_now=True)),
('google_calendar_id', models.CharField(blank=True, max_length=255, null=True)),
('start_event', models.DateTimeField(null=True)),
('end_event', models.DateTimeField(null=True)),
('recurrence_rule', models.TextField()),
('tags', models.ManyToManyField(blank=True, to='tasks.tag')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -1,9 +1,5 @@
from datetime import datetime
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.core import validators
from django.utils import timezone
class Tag(models.Model): class Tag(models.Model):
""" """
@ -16,21 +12,16 @@ class Tag(models.Model):
class Task(models.Model): class Task(models.Model):
""" """
Represents a task, such as Habit, Daily, Todo, or Reward. Represents a Abstract of task, such as Habit, Daily, Todo, or Reward.
:param type: The type of the tasks :param user: The user who owns the task.
:param title: Title of the task. :param title: Title of the task.
:param notes: Optional additional notes for the task. :param notes: Optional additional notes for the task.
:param tags: Associated tags for the task. :param tags: Associated tags for the task.
:param completed: A boolean field indicating whether the task is completed. :param completed: A boolean field indicating whether the task is completed.
:param exp: The experience values user will get from the task.
:param priority: The priority of the task (1, 2, .., 4), using Eisenhower Matrix Idea.
:param importance: The importance of the task (range: 1 to 5) :param importance: The importance of the task (range: 1 to 5)
:param difficulty: The difficulty of the task (range: 1 to 5). :param difficulty: The difficulty of the task (range: 1 to 5).
:param attribute: The attribute linked to the task
:param user: The user who owns the task.
:param challenge: Associated challenge (optional). :param challenge: Associated challenge (optional).
:param reminders: A Many-to-Many relationship with Reminder.
:param fromSystem: A boolean field indicating if the task is from System. :param fromSystem: A boolean field indicating if the task is from System.
:param creation_date: Creation date of the task. :param creation_date: Creation date of the task.
:param last_update: Last updated date of the task. :param last_update: Last updated date of the task.
@ -38,87 +29,49 @@ class Task(models.Model):
:param start_event: Start event of the task. :param start_event: Start event of the task.
:param end_event: End event(Due Date) of the task. :param end_event: End event(Due Date) of the task.
""" """
TASK_TYPES = [ class Difficulty(models.IntegerChoices):
('daily', 'Daily'), EASY = 1, 'Easy'
('habit', 'Habit'), NORMAL = 2, 'Normal'
('todo', 'Todo'), HARD = 3, 'Hard'
('Long Term Goal', 'Long Term Goal'), VERY_HARD = 4, 'Very Hard'
] DEVIL = 5, 'Devil'
DIFFICULTY_CHOICES = [ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
(1, 'Easy'),
(2, 'Normal'),
(3, 'Hard'),
(4, 'Very Hard'),
(5, 'Devil'),
]
ATTRIBUTE = [
('str', 'Strength'),
('int', 'Intelligence'),
('end', 'Endurance'),
('per', 'Perception'),
('luck', 'Luck'),
]
EISENHOWER_MATRIX = [
(1, 'Important & Urgent'),
(2, 'Important & Not Urgent'),
(3, 'Not Important & Urgent'),
(4, 'Not Important & Not Urgent'),
]
type = models.CharField(max_length=15, choices=TASK_TYPES, default='habit')
title = models.TextField() title = models.TextField()
notes = models.TextField(default='') notes = models.TextField(default='')
tags = models.ManyToManyField(Tag, blank=True) tags = models.ManyToManyField(Tag, blank=True)
completed = models.BooleanField(default=False)
exp = models.FloatField(default=0)
priority = models.PositiveSmallIntegerField(choices=EISENHOWER_MATRIX, default=4)
importance = models.PositiveSmallIntegerField(choices=[(i, str(i)) for i in range(1, 6)], default=1) importance = models.PositiveSmallIntegerField(choices=[(i, str(i)) for i in range(1, 6)], default=1)
difficulty = models.PositiveSmallIntegerField(choices=DIFFICULTY_CHOICES, default=1) difficulty = models.PositiveSmallIntegerField(choices=Difficulty.choices, default=Difficulty.EASY)
attribute = models.CharField(max_length=15, choices=ATTRIBUTE, default='str')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
challenge = models.BooleanField(default=False) challenge = models.BooleanField(default=False)
fromSystem = models.BooleanField(default=False) fromSystem = models.BooleanField(default=False)
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
last_update = models.DateTimeField(auto_now=True) last_update = models.DateTimeField(auto_now=True)
google_calendar_id = models.CharField(blank=True, null=True, max_length=255) google_calendar_id = models.CharField(max_length=255, null=True, blank=True)
start_event = models.DateTimeField(null=True) start_event = models.DateTimeField(null=True)
end_event = models.DateTimeField(null=True) end_event = models.DateTimeField(null=True)
def calculate_eisenhower_matrix_category(self):
"""
Classify the task into one of the four categories in the Eisenhower Matrix.
:return: The category of the task (1, 2, 3, or 4).
"""
if self.end_event:
time_until_due = (self.end_event - datetime.now(timezone.utc)).days
else:
time_until_due = float('inf')
urgency_threshold = 3
importance_threshold = 3
if time_until_due <= urgency_threshold and self.importance >= importance_threshold:
return 1
elif time_until_due > urgency_threshold and self.importance >= importance_threshold:
return 2
elif time_until_due <= urgency_threshold and self.importance < importance_threshold:
return 3
else:
return 4
def save(self, *args, **kwargs):
self.priority = self.calculate_eisenhower_matrix_category()
super(Task, self).save(*args, **kwargs)
class Meta: class Meta:
verbose_name = 'Task' abstract = True
verbose_name_plural = 'Tasks'
class Todo(Task):
class EisenhowerMatrix(models.IntegerChoices):
IMPORTANT_URGENT = 1, 'Important & Urgent'
IMPORTANT_NOT_URGENT = 2, 'Important & Not Urgent'
NOT_IMPORTANT_URGENT = 3, 'Not Important & Urgent'
NOT_IMPORTANT_NOT_URGENT = 4, 'Not Important & Not Urgent'
priority = models.PositiveSmallIntegerField(choices=EisenhowerMatrix.choices, default=EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT)
def __str__(self):
return self.title
class RecurrenceTask(Task):
recurrence_rule = models.TextField()
def __str__(self) -> str:
return f"{self.title} ({self.recurrence_rule})"
class Subtask(models.Model): class Subtask(models.Model):
""" """
@ -127,9 +80,9 @@ class Subtask(models.Model):
:param completed: A boolean field indicating whether the subtask is completed. :param completed: A boolean field indicating whether the subtask is completed.
:param parent_task: The parent task of the subtask. :param parent_task: The parent task of the subtask.
""" """
parent_task = models.ForeignKey(Todo, on_delete=models.CASCADE)
description = models.TextField() description = models.TextField()
completed = models.BooleanField(default=False) completed = models.BooleanField(default=False)
parent_task = models.ForeignKey(Task, on_delete=models.CASCADE)
class UserNotification(models.Model): class UserNotification(models.Model):

View File

@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
from .models import Task from .models import Todo, RecurrenceTask
class GoogleCalendarEventSerializer(serializers.Serializer): class GoogleCalendarEventSerializer(serializers.Serializer):
@ -10,7 +10,7 @@ class GoogleCalendarEventSerializer(serializers.Serializer):
description = serializers.CharField(required=False) description = serializers.CharField(required=False)
class TaskUpdateSerializer(serializers.ModelSerializer): class TodoUpdateSerializer(serializers.ModelSerializer):
id = serializers.CharField(source="google_calendar_id") id = serializers.CharField(source="google_calendar_id")
summary = serializers.CharField(source="title") summary = serializers.CharField(source="title")
description = serializers.CharField(source="notes", required=False) description = serializers.CharField(source="notes", required=False)
@ -21,15 +21,41 @@ class TaskUpdateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Task model = Todo
fields = ('id', 'summary', 'description', 'created', 'updated', 'start_datetime', 'end_datetime') fields = ('id', 'summary', 'description', 'created', 'updated', 'start_datetime', 'end_datetime')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None) self.user = kwargs.pop('user', None)
super(TaskUpdateSerializer, self).__init__(*args, **kwargs) super(TodoUpdateSerializer, self).__init__(*args, **kwargs)
def create(self, validated_data): def create(self, validated_data):
validated_data['user'] = self.user validated_data['user'] = self.user
task = Task.objects.create(**validated_data) task = Todo.objects.create(**validated_data)
return task
class RecurrenceTaskUpdateSerializer(serializers.ModelSerializer):
id = serializers.CharField(source="google_calendar_id")
summary = serializers.CharField(source="title")
description = serializers.CharField(source="notes", required=False)
created = serializers.DateTimeField(source="creation_date")
updated = serializers.DateTimeField(source="last_update")
recurrence = serializers.DateTimeField(source="recurrence_rule")
start_datetime = serializers.DateTimeField(source="start_event", required=False)
end_datetime = serializers.DateTimeField(source="end_event", required=False)
class Meta:
model = RecurrenceTask
fields = ('id', 'summary', 'description', 'created', 'updated', 'recurrence', 'start_datetime', 'end_datetime')
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user', None)
super(RecurrenceTaskUpdateSerializer, self).__init__(*args, **kwargs)
def create(self, validated_data):
validated_data['user'] = self.user
task = RecurrenceTask.objects.create(**validated_data)
return task return task

25
backend/tasks/signals.py Normal file
View File

@ -0,0 +1,25 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils import timezone
from tasks.models import Todo
@receiver(pre_save, sender=Todo)
def update_priority(sender, instance, **kwargs):
if instance.end_event:
time_until_due = (instance.end_event - timezone.now()).days
else:
time_until_due = float('inf')
urgency_threshold = 3
importance_threshold = 3
if time_until_due <= urgency_threshold and instance.importance >= importance_threshold:
instance.priority = Todo.EisenhowerMatrix.IMPORTANT_URGENT
elif time_until_due > urgency_threshold and instance.importance >= importance_threshold:
instance.priority = Todo.EisenhowerMatrix.IMPORTANT_NOT_URGENT
elif time_until_due <= urgency_threshold and instance.importance < importance_threshold:
instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_URGENT
else:
instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT

View File

@ -1,21 +1,21 @@
from rest_framework import serializers from rest_framework import serializers
from ..models import Task from ..models import Todo
class TaskCreateSerializer(serializers.ModelSerializer): class TaskCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Task model = Todo
# fields = '__all__' # fields = '__all__'
exclude = ('tags',) exclude = ('tags',)
def create(self, validated_data): def create(self, validated_data):
# Create a new task with validated data # Create a new task with validated data
return Task.objects.create(**validated_data) return Todo.objects.create(**validated_data)
class TaskGeneralSerializer(serializers.ModelSerializer): class TaskGeneralSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Task model = Todo
fields = '__all__' fields = '__all__'
def create(self, validated_data): def create(self, validated_data):
# Create a new task with validated data # Create a new task with validated data
return Task.objects.create(**validated_data) return Todo.objects.create(**validated_data)

View File

@ -1,37 +1,16 @@
from rest_framework import status from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.generics import CreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, DestroyAPIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from ..models import Task from tasks.models import Todo
from .serializers import TaskCreateSerializer, TaskGeneralSerializer from .serializers import TaskCreateSerializer, TaskGeneralSerializer
class TaskCreateView(CreateAPIView):
queryset = Task.objects.all()
serializer_class = TaskCreateSerializer
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs): class TodoViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=request.data) queryset = Todo.objects.all()
if serializer.is_valid():
self.perform_create(serializer)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class TaskRetrieveView(RetrieveAPIView):
queryset = Task.objects.all()
serializer_class = TaskGeneralSerializer serializer_class = TaskGeneralSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get_serializer_class(self):
class TaskUpdateView(RetrieveUpdateAPIView): # Can't add ManytoMany at creation time (Tags)
queryset = Task.objects.all() if self.action == 'create':
serializer_class = TaskGeneralSerializer return TaskCreateSerializer
permission_classes = [IsAuthenticated] return TaskGeneralSerializer
class TaskDeleteView(DestroyAPIView):
queryset = Task.objects.all()
permission_classes = [IsAuthenticated]

View File

@ -5,8 +5,8 @@ from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from tasks.tests.utils import create_test_user, login_user from tasks.tests.utils import create_test_user, login_user
from tasks.serializers import TaskUpdateSerializer from tasks.serializers import TodoUpdateSerializer
from tasks.models import Task from tasks.models import Todo
class TaskUpdateSerializerTest(TestCase): class TaskUpdateSerializerTest(TestCase):
def setUp(self): def setUp(self):
@ -25,14 +25,14 @@ class TaskUpdateSerializerTest(TestCase):
'end_datetie': self.end_time, 'end_datetie': self.end_time,
} }
serializer = TaskUpdateSerializer(data=data, user=self.user) serializer = TodoUpdateSerializer(data=data, user=self.user)
self.assertTrue(serializer.is_valid()) self.assertTrue(serializer.is_valid())
serializer.is_valid() serializer.is_valid()
task = serializer.save() task = serializer.save()
self.assertIsInstance(task, Task) self.assertIsInstance(task, Todo)
def test_serializer_update(self): def test_serializer_update(self):
task = Task.objects.create(title='Original Task', notes='Original description', user=self.user) task = Todo.objects.create(title='Original Task', notes='Original description', user=self.user)
data = { data = {
'id': '32141cwaNcapufh8jq2conw', 'id': '32141cwaNcapufh8jq2conw',
@ -44,7 +44,7 @@ class TaskUpdateSerializerTest(TestCase):
'end_datetie': self.end_time, 'end_datetie': self.end_time,
} }
serializer = TaskUpdateSerializer(instance=task, data=data) serializer = TodoUpdateSerializer(instance=task, data=data)
self.assertTrue(serializer.is_valid()) self.assertTrue(serializer.is_valid())
updated_task = serializer.save() updated_task = serializer.save()

View File

@ -1,38 +0,0 @@
from datetime import datetime, timedelta, timezone
from django.test import TestCase
from tasks.models import Task
from tasks.tests.utils import create_test_user
class TaskModelTest(TestCase):
def setUp(self):
self.user = create_test_user()
def test_eisenhower_matrix_category(self):
task = Task(importance=2, end_event=None, user=self.user)
task.save()
# 'Not Important & Not Urgent' category (category 4)
self.assertEqual(task.calculate_eisenhower_matrix_category(), 4)
due_date = datetime.now(timezone.utc) + timedelta(days=1)
task = Task(importance=4, end_event=due_date, user=self.user)
task.save()
# 'Important & Urgent' category (category 1)
self.assertEqual(task.calculate_eisenhower_matrix_category(), 1)
due_date = datetime.now(timezone.utc) + timedelta(days=10)
task = Task(importance=3, end_event=due_date, user=self.user)
task.save()
# 'Important & Not Urgent' category (category 2)
self.assertEqual(task.calculate_eisenhower_matrix_category(), 2)
due_date = datetime.now(timezone.utc) + timedelta(days=4)
task = Task(importance=1, end_event=due_date, user=self.user)
task.save()
# 'Not Important & Urgent' category (category 3)
self.assertEqual(task.calculate_eisenhower_matrix_category(), 3)

View File

@ -1,22 +1,19 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from tasks.tests.utils import create_test_user, login_user from tasks.tests.utils import create_test_user, login_user
from ..models import Task from tasks.models import Todo
class TaskCreateViewTests(APITestCase):
class TodoViewSetTests(APITestCase):
def setUp(self): def setUp(self):
self.user = create_test_user() self.user = create_test_user()
self.client = login_user(self.user) self.client = login_user(self.user)
self.url = reverse("add-task") self.url = reverse("todo-list")
self.due_date = datetime.now() + timedelta(days=5) self.due_date = datetime.now() + timedelta(days=5)
def test_create_valid_todo(self):
def test_create_valid_task(self):
""" """
Test creating a valid task using the API. Test creating a valid task using the API.
""" """
@ -28,24 +25,23 @@ class TaskCreateViewTests(APITestCase):
'priority': 1, 'priority': 1,
'difficulty': 1, 'difficulty': 1,
'user': self.user.id, 'user': self.user.id,
'end_event': self.due_date, 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'),
} }
response = self.client.post(self.url, data, format='json') response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Task.objects.count(), 1) self.assertEqual(Todo.objects.count(), 1)
self.assertEqual(Task.objects.get().title, 'Test Task') self.assertEqual(Todo.objects.get().title, 'Test Task')
def test_create_invalid_task(self): def test_create_invalid_todo(self):
""" """
Test creating an invalid task using the API. Test creating an invalid task using the API.
""" """
data = { data = {
'type': 'invalid', # Invalid task type 'type': 'invalid', # Invalid task type
} }
response = self.client.post(self.url, data, format='json') response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Task.objects.count(), 0) # No task should be created self.assertEqual(Todo.objects.count(), 0) # No task should be created
def test_missing_required_fields(self): def test_missing_required_fields(self):
""" """
@ -55,10 +51,9 @@ class TaskCreateViewTests(APITestCase):
'title': 'Incomplete Task', 'title': 'Incomplete Task',
'type': 'habit', 'type': 'habit',
} }
response = self.client.post(self.url, data, format='json') response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Task.objects.count(), 0) # No task should be created self.assertEqual(Todo.objects.count(), 0) # No task should be created
def test_invalid_user_id(self): def test_invalid_user_id(self):
""" """
@ -71,9 +66,8 @@ class TaskCreateViewTests(APITestCase):
'priority': 1, 'priority': 1,
'difficulty': 1, 'difficulty': 1,
'user': 999, # Invalid user ID 'user': 999, # Invalid user ID
'end_event': self.due_date, 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'),
} }
response = self.client.post(self.url, data, format='json') response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Task.objects.count(), 0) # No task should be created self.assertEqual(Todo.objects.count(), 0) # No task should be created

View File

@ -0,0 +1,36 @@
from datetime import datetime, timedelta, timezone
from django.test import TestCase
from tasks.models import Todo
from tasks.tests.utils import create_test_user
class TodoPriorityTest(TestCase):
def setUp(self):
self.user = create_test_user()
def test_priority_calculation(self):
# Important = 2, Till Due = none
todo = Todo(importance=2, end_event=None, user=self.user)
todo.save()
# 'Not Important & Not Urgent'
self.assertEqual(todo.priority, Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT)
due_date = datetime.now(timezone.utc) + timedelta(days=1)
# Important = 4, Till Due = 1
todo = Todo(importance=4, end_event=due_date, user=self.user)
todo.save()
# 'Important & Urgent'
self.assertEqual(todo.priority, Todo.EisenhowerMatrix.IMPORTANT_URGENT)
due_date = datetime.now(timezone.utc) + timedelta(days=10)
# Important = 3, Till Due = 10
todo = Todo(importance=3, end_event=due_date, user=self.user)
todo.save()
# 'Important & Not Urgent'
self.assertEqual(todo.priority, Todo.EisenhowerMatrix.IMPORTANT_NOT_URGENT)
due_date = datetime.now(timezone.utc) + timedelta(days=2)
# Important = 1, Till Due = 2
todo = Todo(importance=1, end_event=due_date, user=self.user)
todo.save()
# 'Not Important & Urgent'
self.assertEqual(todo.priority, Todo.EisenhowerMatrix.NOT_IMPORTANT_URGENT)

View File

@ -1,7 +1,7 @@
from rest_framework.test import APIClient from rest_framework.test import APIClient
from users.models import CustomUser from users.models import CustomUser
from ..models import Task from ..models import Todo
def create_test_user(email="testusertestuser@example.com", username="testusertestuser", def create_test_user(email="testusertestuser@example.com", username="testusertestuser",
@ -61,4 +61,4 @@ def create_test_task(user, **kwargs):
task_attributes = {**defaults, **kwargs} task_attributes = {**defaults, **kwargs}
return Task.objects.create(user=user, **task_attributes) return Todo.objects.create(user=user, **task_attributes)

View File

@ -1,17 +1,17 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .api import GoogleCalendarEventViewset
from .tasks.views import TaskCreateView, TaskRetrieveView, TaskUpdateView, TaskDeleteView from tasks.api import GoogleCalendarEventViewset
from .misc.views import TagViewSet from tasks.tasks.views import TodoViewSet
from tasks.misc.views import TagViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'todo', TodoViewSet)
router.register(r'tags', TagViewSet) router.register(r'tags', TagViewSet)
router.register(r'calendar-events', GoogleCalendarEventViewset, basename='calendar-events') router.register(r'calendar-events', GoogleCalendarEventViewset, basename='calendar-events')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('tasks/create/', TaskCreateView.as_view(), name="add-task"),
path('tasks/<int:pk>/', TaskRetrieveView.as_view(), name='retrieve-task'),
path('tasks/<int:pk>/update/', TaskUpdateView.as_view(), name='update-task'),
path('tasks/<int:pk>/delete/', TaskDeleteView.as_view(), name='delete-task'),
] ]

View File

@ -4,3 +4,6 @@ from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'users' name = 'users'
def ready(self):
import users.signals

View File

@ -0,0 +1,30 @@
# Generated by Django 4.2.6 on 2023-11-06 05:30
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0003_customuser_profile_pic'),
]
operations = [
migrations.CreateModel(
name='UserStats',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('health', models.IntegerField(default=100)),
('gold', models.FloatField(default=0.0)),
('experience', models.FloatField(default=0)),
('strength', models.IntegerField(default=1)),
('intelligence', models.IntegerField(default=1)),
('endurance', models.IntegerField(default=1)),
('perception', models.IntegerField(default=1)),
('luck', models.IntegerField(default=1)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,7 +1,11 @@
import random
import math
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.core.validators import MinValueValidator, MaxValueValidator
from .managers import CustomAccountManager from .managers import CustomAccountManager
@ -32,15 +36,33 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
return self.username return self.username
# class UserStats(models.Model): def random_luck():
# """ return random.randint(1, 50)
# Represents User Profiles and Attributes.
# Fields: class UserStats(models.Model):
# - health: health points of the user. """
# - gold: gold points of the user. Represents User Profiles and Attributes.
# - experience: experience points of the user. Fields:
# """ - health: health points of the user.
# user = models.OneToOneField(CustomUser, on_delete=models.CASCADE) - gold: gold points of the user.
# health = models.IntegerField(default=100) - experience: experience points of the user.
# gold = models.IntegerField(default=0) """
# experience = models.FloatField(default=0) user = models.OneToOneField(CustomUser, on_delete=models.CASCADE)
health = models.IntegerField(default=100)
gold = models.FloatField(default=0.0)
experience = models.FloatField(default=0)
strength = models.IntegerField(default=1,
validators=[MinValueValidator(1),
MaxValueValidator(100)])
intelligence = models.IntegerField(default=1, validators=[MinValueValidator(1),
MaxValueValidator(100)])
endurance = models.IntegerField(default=1, validators=[MinValueValidator(1),
MaxValueValidator(100)])
perception = models.IntegerField(default=1, validators=[MinValueValidator(1),
MaxValueValidator(100)])
luck = models.IntegerField(default=random_luck, validators=[MinValueValidator(1),
MaxValueValidator(50)],)
@property
def level(self):
return (math.pow(self.experience, 2) // 225) + 1

9
backend/users/signals.py Normal file
View File

@ -0,0 +1,9 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from users.models import CustomUser, UserStats
@receiver(post_save, sender=CustomUser)
def create_user_stats(sender, instance, created, **kwargs):
if created:
UserStats.objects.create(user=instance)

View File

@ -10,12 +10,23 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@asseinfo/react-kanban": "^2.2.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@fullcalendar/core": "^6.1.9",
"@fullcalendar/daygrid": "^6.1.9",
"@fullcalendar/interaction": "^6.1.9",
"@fullcalendar/react": "^6.1.9",
"@fullcalendar/timegrid": "^6.1.9",
"@mui/icons-material": "^5.14.15", "@mui/icons-material": "^5.14.15",
"@mui/material": "^5.14.15", "@mui/material": "^5.14.15",
"@mui/system": "^5.14.15", "@mui/system": "^5.14.15",
"@react-oauth/google": "^0.11.1", "@react-oauth/google": "^0.11.1",
"@syncfusion/ej2-base": "^23.1.41",
"@syncfusion/ej2-kanban": "^23.1.36",
"axios": "^1.5.1", "axios": "^1.5.1",
"bootstrap": "^5.3.2", "bootstrap": "^5.3.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
@ -23,6 +34,7 @@
"gapi-script": "^1.2.0", "gapi-script": "^1.2.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-bootstrap": "^2.9.1", "react-bootstrap": "^2.9.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,36 @@
import './App.css'; import './App.css';
import { BrowserRouter, Route, Routes, Link } from 'react-router-dom'; import { BrowserRouter, Route, Routes } from 'react-router-dom';
import TestAuth from './components/testAuth'; import TestAuth from './components/testAuth';
import LoginPage from './components/authentication/LoginPage'; import LoginPage from './components/authentication/LoginPage';
import SignUpPage from './components/authentication/SignUpPage'; import SignUpPage from './components/authentication/SignUpPage';
import NavBar from './components/Nav/Navbar'; import NavBar from './components/nav/Navbar';
import Home from './components/Home'; import Home from './components/Home';
import ProfileUpdate from './components/ProfileUpdatePage' import ProfileUpdate from './components/ProfileUpdatePage';
import Calendar from './components/calendar/calendar';
import KanbanBoard from './components/kanbanBoard/kanbanBoard';
import IconSideNav from './components/IconSideNav'; // Import IconSideNav
const App = () => { const App = () => {
return ( return (
<BrowserRouter> <BrowserRouter>
<div className="App"> <div className='display: flex'>
<NavBar/> <IconSideNav />
<div className='flex-1'>
<NavBar />
<div className='flex items-center justify-center'>
<Routes> <Routes>
<Route path="/" element={<Home/>}/> <Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage/>}/> <Route path="/tasks" element={<KanbanBoard />} />
<Route path="/signup" element={<SignUpPage/>}/> <Route path="/login" element={<LoginPage />} />
<Route path="/testAuth" element={<TestAuth/>}/> <Route path="/signup" element={<SignUpPage />} />
<Route path="/update_profile" element={<ProfileUpdate/>}/> <Route path="/testAuth" element={<TestAuth />} />
<Route path="/update_profile" element={<ProfileUpdate />} />
<Route path="/calendar" element={<Calendar />} />
</Routes> </Routes>
</div> </div>
</div>
</div>
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@ -0,0 +1,23 @@
import axios from 'axios';
// Create an Axios instance with common configurations
const axiosInstance = axios.create({
baseURL: 'http://127.0.0.1:8000/api/',
timeout: 5000,
headers: {
'Authorization': "Bearer " + localStorage.getItem('access_token'),
'Content-Type': 'application/json',
'accept': 'application/json',
}
});
export const fetchTodoTasks = () => {
return axiosInstance
.get('todo/')
.then((response) => {
return response.data;
})
.catch(error => {
throw error;
});
};

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { SiFramer, SiTailwindcss, SiReact, SiJavascript, SiCss3 } from "react-icons/si"; import { Link, useNavigate } from "react-router-dom";
import homeLogo from "../assets/home.png"; import homeLogo from "../assets/home.png";
import calendarLogo from "../assets/calendar.png"; import calendarLogo from "../assets/calendar.png";
import planLogo from "../assets/planning.png"; import planLogo from "../assets/planning.png";
@ -8,18 +8,17 @@ import pieLogo from "../assets/pie-chart.png";
import plusLogo from "../assets/plus.png"; import plusLogo from "../assets/plus.png";
const menuItems = [ const menuItems = [
{ id: 0, icon: <homeLogo />, logo: homeLogo }, { id: 0, path: "/", icon: <homeLogo />, logo: homeLogo },
{ id: 1, icon: <calendarLogo />, logo: calendarLogo }, { id: 1, path: "/tasks", icon: <planLogo />, logo: planLogo },
{ id: 2, icon: <planLogo />, logo: planLogo }, { id: 2, path: "/calendar", icon: <calendarLogo />, logo: calendarLogo },
{ id: 3, icon: <pieLogo />, logo: pieLogo }, { id: 3, path: "/pie", icon: <pieLogo />, logo: pieLogo },
{ id: 4, icon: <plusLogo />, logo: plusLogo }, { id: 4, path: "/plus", icon: <plusLogo />, logo: plusLogo },
]; ];
const IconSideNav = () => { const IconSideNav = () => {
return ( return (
<div className="bg-slate-900 text-slate-100 flex"> <div className="bg-slate-900 text-slate-100 flex">
<SideNav /> <SideNav />
<div className="w-full"></div>
</div> </div>
); );
}; };
@ -28,7 +27,7 @@ const SideNav = () => {
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0);
return ( return (
<nav className="h-[500px] w-fit bg-slate-950 p-4 flex flex-col items-center gap-2"> <nav className="bg-slate-950 p-4 flex flex-col items-center gap-2 h-screen">
{menuItems.map((item) => ( {menuItems.map((item) => (
<NavItem <NavItem
key={item.id} key={item.id}
@ -37,17 +36,23 @@ const SideNav = () => {
id={item.id} id={item.id}
setSelected={setSelected} setSelected={setSelected}
logo={item.logo} logo={item.logo}
path={item.path}
/> />
))} ))}
</nav> </nav>
); );
}; };
const NavItem = ({ icon, selected, id, setSelected, logo }) => { const NavItem = ({ icon, selected, id, setSelected, logo, path }) => {
const navigate = useNavigate();
return ( return (
<motion.button <motion.button
className="p-3 text-xl bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative" className="p-3 text-xl bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative"
onClick={() => setSelected(id)} onClick={() => {
setSelected(id);
navigate(path);
}}
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >

View File

@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import IsAuthenticated from '../authentication/IsAuthenticated'; import IsAuthenticated from '../authentication/IsAuthenticated';
import axiosapi from '../../api/axiosapi'; import axiosapi from '../../api/AuthenticationApi';
import AppBar from '@mui/material/AppBar'; import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar'; import Toolbar from '@mui/material/Toolbar';

View File

@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useGoogleLogin } from "@react-oauth/google" import { useGoogleLogin } from "@react-oauth/google"
import refreshAccessToken from './refreshAcesstoken'; import refreshAccessToken from './refreshAcesstoken';
import axiosapi from '../../api/axiosapi'; import axiosapi from '../../api/AuthenticationApi';
function LoginPage() { function LoginPage() {
const Navigate = useNavigate(); const Navigate = useNavigate();

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import axiosapi from '../../api/axiosapi'; import axiosapi from '../../api/AuthenticationApi';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';

View File

@ -0,0 +1,42 @@
import { fetchTodoTasks } from '../../api/TaskApi';
let eventGuid = 0
// function getDateAndTime(dateString) {
// const dateObject = new Date(dateString);
// const year = dateObject.getFullYear();
// const month = (dateObject.getMonth() + 1).toString().padStart(2, '0');
// const day = dateObject.getDate().toString().padStart(2, '0');
// const dateFormatted = `${year}-${month}-${day}`;
// const hours = dateObject.getUTCHours().toString().padStart(2, '0');
// const minutes = dateObject.getUTCMinutes().toString().padStart(2, '0');
// const seconds = dateObject.getUTCSeconds().toString().padStart(2, '0');
// const timeFormatted = `T${hours}:${minutes}:${seconds}`;
// return dateFormatted + timeFormatted;
// }
const mapResponseToEvents = (response) => {
return response.map(item => ({
id: createEventId(),
title: item.title,
start: item.start_event,
end: item.end_event,
}));
}
export async function getEvents() {
try {
const response = await fetchTodoTasks();
return mapResponseToEvents(response);
} catch (error) {
console.error(error);
return [];
}
}
export function createEventId() {
return String(eventGuid++);
}

View File

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { formatDate } from "@fullcalendar/core";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import { getEvents, createEventId } from "./TaskDataHandler";
import './index.css'
export default class Calendar extends React.Component {
state = {
weekendsVisible: true,
currentEvents: [],
};
render() {
return (
<div className="demo-app">
{this.renderSidebar()}
<div className="demo-app-main">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}}
initialView="dayGridMonth"
editable={true}
selectable={true}
selectMirror={true}
dayMaxEvents={true}
weekends={this.state.weekendsVisible}
initialEvents={getEvents} // alternatively, use the `events` setting to fetch from a feed
select={this.handleDateSelect}
eventContent={renderEventContent} // custom render function
eventClick={this.handleEventClick}
eventsSet={this.handleEvents} // called after events are initialized/added/changed/removed
/* you can update a remote database when these fire:
eventAdd={function(){}}
eventChange={function(){}}
eventRemove={function(){}}
*/
/>
</div>
</div>
);
}
renderSidebar() {
return (
<div className="demo-app-sidebar">
<div className="demo-app-sidebar-section">
<h2>Instructions</h2>
<ul>
<li>Select dates and you will be prompted to create a new event</li>
<li>Drag, drop, and resize events</li>
<li>Click an event to delete it</li>
</ul>
</div>
<div className="demo-app-sidebar-section">
<label>
<input type="checkbox" checked={this.state.weekendsVisible} onChange={this.handleWeekendsToggle}></input>
toggle weekends
</label>
</div>
<div className="demo-app-sidebar-section">
<h2>All Events ({this.state.currentEvents.length})</h2>
<ul>{this.state.currentEvents.map(renderSidebarEvent)}</ul>
</div>
</div>
);
}
handleWeekendsToggle = () => {
this.setState({
weekendsVisible: !this.state.weekendsVisible,
});
};
handleDateSelect = selectInfo => {
let title = prompt("Please enter a new title for your event");
let calendarApi = selectInfo.view.calendar;
calendarApi.unselect(); // clear date selection
if (title) {
calendarApi.addEvent({
id: createEventId(),
title,
start: selectInfo.startStr,
end: selectInfo.endStr,
allDay: selectInfo.allDay,
});
}
};
handleEventClick = clickInfo => {
if (confirm(`Are you sure you want to delete the event '${clickInfo.event.title}'`)) {
clickInfo.event.remove();
}
};
handleEvents = events => {
this.setState({
currentEvents: events,
});
};
}
function renderEventContent(eventInfo) {
return (
<>
<b>{eventInfo.timeText}</b>
<i>{eventInfo.event.title}</i>
</>
);
}
function renderSidebarEvent(event) {
return (
<li key={event.id}>
<b>{formatDate(event.start, { year: "numeric", month: "short", day: "numeric" })}</b>
<i>{event.title}</i>
</li>
);
}

View File

@ -0,0 +1,55 @@
html,
body,
body > div { /* the react root */
margin: 0;
padding: 0;
height: 100%;
}
h2 {
margin: 0;
font-size: 16px;
}
ul {
margin: 0;
padding: 0 0 0 1.5em;
}
li {
margin: 1.5em 0;
padding: 0;
}
b { /* used for event dates/times */
margin-right: 3px;
}
.demo-app {
display: flex;
min-height: 100%;
font-family: Arial, Helvetica Neue, Helvetica, sans-serif;
font-size: 14px;
}
.demo-app-sidebar {
width: 300px;
line-height: 1.5;
background: #eaf9ff;
border-right: 1px solid #d3e2e8;
}
.demo-app-sidebar-section {
padding: 2em;
}
.demo-app-main {
flex-grow: 1;
padding: 3em;
}
.fc { /* the calendar root */
max-width: 1100px;
margin: 0 auto;
}

View File

@ -0,0 +1,22 @@
import React from 'react';
function PlusIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export default PlusIcon;

View File

@ -0,0 +1,23 @@
import React from 'react';
function TrashIcon() {
return (
React.createElement(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
fill: "none",
viewBox: "0 0 24 24",
strokeWidth: 1.5,
className: "w-6 h-6"
},
React.createElement("path", {
strokeLinecap: "round",
strokeLinejoin: "round",
d: "M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
})
)
);
}
export default TrashIcon;

View File

@ -0,0 +1,178 @@
import { SortableContext, useSortable } from "@dnd-kit/sortable";
import TrashIcon from "../icons/trashIcon";
import { CSS } from "@dnd-kit/utilities";
import { useMemo, useState } from "react";
import PlusIcon from "../icons/plusIcon";
import TaskCard from "./taskCard";
function ColumnContainer({
column,
deleteColumn,
updateColumn,
createTask,
tasks,
deleteTask,
updateTask,
}) {
const [editMode, setEditMode] = useState(false);
const tasksIds = useMemo(() => {
return tasks.map((task) => task.id);
}, [tasks]);
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: column.id,
data: {
type: "Column",
column,
},
disabled: editMode,
});
const style = {
transition,
transform: CSS.Transform.toString(transform),
};
if (isDragging) {
return (
<div
ref={setNodeRef}
style={style}
className="
bg-columnBackgroundColor
opacity-40
border-2
border-pink-500
w-[350px]
h-[500px]
max-h-[500px]
rounded-md
flex
flex-col
"
></div>
);
}
return (
<div
ref={setNodeRef}
style={style}
className="
bg-columnBackgroundColor
w-[350px]
h-[500px]
max-h-[500px]
rounded-md
flex
flex-col
"
>
{/* Column title */}
<div
{...attributes}
{...listeners}
onClick={() => {
setEditMode(true);
}}
className="
bg-mainBackgroundColor
text-md
h-[60px]
cursor-grab
rounded-md
rounded-b-none
p-3
font-bold
border-columnBackgroundColor
border-4
flex
items-center
justify-between
"
>
<div className="flex gap-2">
<div
className="
flex
justify-center
items-center
bg-columnBackgroundColor
px-2
py-1
text-sm
rounded-full
"
>
0
</div>
{!editMode && column.title}
{editMode && (
<input
className="bg-black focus:border-rose-500 border rounded outline-none px-2"
value={column.title}
onChange={(e) => updateColumn(column.id, e.target.value)}
autoFocus
onBlur={() => {
setEditMode(false);
}}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
setEditMode(false);
}}
/>
)}
</div>
<button
onClick={() => {
deleteColumn(column.id);
}}
className="
stroke-gray-500
hover:stroke-white
hover:bg-columnBackgroundColor
rounded
px-1
py-2
"
>
<TrashIcon />
</button>
</div>
{/* Column task container */}
<div className="flex flex-grow flex-col gap-4 p-2 overflow-x-hidden overflow-y-auto">
<SortableContext items={tasksIds}>
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
deleteTask={deleteTask}
updateTask={updateTask}
/>
))}
</SortableContext>
</div>
{/* Column footer */}
<button
className="flex gap-2 items-center border-columnBackgroundColor border-2 rounded-md p-4 border-x-columnBackgroundColor hover:bg-mainBackgroundColor hover:text-rose-500 active:bg-black"
onClick={() => {
createTask(column.id);
}}
>
<PlusIcon />
Add task
</button>
</div>
);
}
export default ColumnContainer;

View File

@ -0,0 +1,336 @@
import PlusIcon from "../icons/plusIcon"
import { useMemo, useState } from "react";
import ColumnContainer from "./columnContainer";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
import { createPortal } from "react-dom";
import TaskCard from "./taskCard";
const defaultCols = [
{
id: "todo",
title: "Todo",
},
{
id: "doing",
title: "Work in progress",
},
{
id: "done",
title: "Done",
},
];
const defaultTasks = [
{
id: "1",
columnId: "todo",
content: "List admin APIs for dashboard",
},
{
id: "2",
columnId: "todo",
content:
"Develop user registration functionality with OTP delivered on SMS after email confirmation and phone number confirmation",
},
{
id: "3",
columnId: "doing",
content: "Conduct security testing",
},
{
id: "4",
columnId: "doing",
content: "Analyze competitors",
},
{
id: "5",
columnId: "done",
content: "Create UI kit documentation",
},
{
id: "6",
columnId: "done",
content: "Dev meeting",
},
{
id: "7",
columnId: "done",
content: "Deliver dashboard prototype",
},
{
id: "8",
columnId: "todo",
content: "Optimize application performance",
},
{
id: "9",
columnId: "todo",
content: "Implement data validation",
},
{
id: "10",
columnId: "todo",
content: "Design database schema",
},
{
id: "11",
columnId: "todo",
content: "Integrate SSL web certificates into workflow",
},
{
id: "12",
columnId: "doing",
content: "Implement error logging and monitoring",
},
{
id: "13",
columnId: "doing",
content: "Design and implement responsive UI",
},
];
function KanbanBoard() {
const [columns, setColumns] = useState(defaultCols);
const columnsId = useMemo(() => columns.map((col) => col.id), [columns]);
const [tasks, setTasks] = useState(defaultTasks);
const [activeColumn, setActiveColumn] = useState(null);
const [activeTask, setActiveTask] = useState(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10,
},
})
);
return (
<div
className="
m-auto
flex
w-full
items-center
overflow-x-auto
overflow-y-hidden
"
>
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
>
<div className="m-auto flex gap-4">
<div className="flex gap-4">
<SortableContext items={columnsId}>
{columns.map((col) => (
<ColumnContainer
key={col.id}
column={col}
deleteColumn={deleteColumn}
updateColumn={updateColumn}
createTask={createTask}
deleteTask={deleteTask}
updateTask={updateTask}
tasks={tasks.filter((task) => task.columnId === col.id)}
/>
))}
</SortableContext>
</div>
<button
onClick={() => {
createNewColumn();
}}
className="
h-[60px]
w-[350px]
min-w-[350px]
cursor-pointer
rounded-lg
bg-mainBackgroundColor
border-2
border-columnBackgroundColor
p-4
ring-rose-500
hover:ring-2
flex
gap-2
"
>
<PlusIcon />
Add Column
</button>
</div>
{createPortal(
<DragOverlay>
{activeColumn && (
<ColumnContainer
column={activeColumn}
deleteColumn={deleteColumn}
updateColumn={updateColumn}
createTask={createTask}
deleteTask={deleteTask}
updateTask={updateTask}
tasks={tasks.filter(
(task) => task.columnId === activeColumn.id
)}
/>
)}
{activeTask && (
<TaskCard
task={activeTask}
deleteTask={deleteTask}
updateTask={updateTask}
/>
)}
</DragOverlay>,
document.body
)}
</DndContext>
</div>
);
function createTask(columnId) {
const newTask = {
id: generateId(),
columnId,
content: `Task ${tasks.length + 1}`,
};
setTasks([...tasks, newTask]);
}
function deleteTask(id) {
const newTasks = tasks.filter((task) => task.id !== id);
setTasks(newTasks);
}
function updateTask(id, content) {
const newTasks = tasks.map((task) => {
if (task.id !== id) return task;
return { ...task, content };
});
setTasks(newTasks);
}
function createNewColumn() {
const columnToAdd = {
id: generateId(),
title: `Column ${columns.length + 1}`,
};
setColumns([...columns, columnToAdd]);
}
function deleteColumn(id) {
const filteredColumns = columns.filter((col) => col.id !== id);
setColumns(filteredColumns);
const newTasks = tasks.filter((t) => t.columnId !== id);
setTasks(newTasks);
}
function updateColumn(id, title) {
const newColumns = columns.map((col) => {
if (col.id !== id) return col;
return { ...col, title };
});
setColumns(newColumns);
}
function onDragStart(event) {
if (event.active.data.current?.type === "Column") {
setActiveColumn(event.active.data.current.column);
return;
}
if (event.active.data.current?.type === "Task") {
setActiveTask(event.active.data.current.task);
return;
}
}
function onDragEnd(event) {
setActiveColumn(null);
setActiveTask(null);
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
if (activeId === overId) return;
const isActiveAColumn = active.data.current?.type === "Column";
if (!isActiveAColumn) return;
setColumns((columns) => {
const activeColumnIndex = columns.findIndex((col) => col.id === activeId);
const overColumnIndex = columns.findIndex((col) => col.id === overId);
return arrayMove(columns, activeColumnIndex, overColumnIndex);
});
}
function onDragOver(event) {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
if (activeId === overId) return;
const isActiveATask = active.data.current?.type === "Task";
const isOverATask = over.data.current?.type === "Task";
if (!isActiveATask) return;
if (isActiveATask && isOverATask) {
setTasks((tasks) => {
const activeIndex = tasks.findIndex((t) => t.id === activeId);
const overIndex = tasks.findIndex((t) => t.id === overId);
if (tasks[activeIndex].columnId !== tasks[overIndex].columnId) {
tasks[activeIndex].columnId = tasks[overIndex].columnId;
return arrayMove(tasks, activeIndex, overIndex - 1);
}
return arrayMove(tasks, activeIndex, overIndex);
});
}
const isOverAColumn = over.data.current?.type === "Column";
if (isActiveATask && isOverAColumn) {
setTasks((tasks) => {
const activeIndex = tasks.findIndex((t) => t.id === activeId);
tasks[activeIndex].columnId = overId;
return arrayMove(tasks, activeIndex, activeIndex);
});
}
}
function generateId() {
return Math.floor(Math.random() * 10001);
}
}
export default KanbanBoard;

View File

@ -0,0 +1,111 @@
import { useState } from "react";
import TrashIcon from "../icons/trashIcon";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
function TaskCard({ task, deleteTask, updateTask }) {
const [mouseIsOver, setMouseIsOver] = useState(false);
const [editMode, setEditMode] = useState(true);
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: task.id,
data: {
type: "Task",
task,
},
disabled: editMode,
});
const style = {
transition,
transform: CSS.Transform.toString(transform),
};
const toggleEditMode = () => {
setEditMode((prev) => !prev);
setMouseIsOver(false);
};
if (isDragging) {
return (
<div
ref={setNodeRef}
style={style}
className="
opacity-30
bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl border-2 border-rose-500 cursor-grab relative
"
/>
);
}
if (editMode) {
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative"
>
<textarea
className="
h-[90%]
w-full resize-none border-none rounded bg-transparent text-white focus:outline-none
"
value={task.content}
autoFocus
placeholder="Task content here"
onBlur={toggleEditMode}
onKeyDown={(e) => {
if (e.key === "Enter" && e.shiftKey) {
toggleEditMode();
}
}}
onChange={(e) => updateTask(task.id, e.target.value)}
/>
</div>
);
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={toggleEditMode}
className="bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative task"
onMouseEnter={() => {
setMouseIsOver(true);
}}
onMouseLeave={() => {
setMouseIsOver(false);
}}
>
<p className="my-auto h-[90%] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap">
{task.content}
</p>
{mouseIsOver && (
<button
onClick={() => {
deleteTask(task.id);
}}
className="stroke-white absolute right-4 top-1/2 -translate-y-1/2 bg-columnBackgroundColor p-2 rounded opacity-60 hover:opacity-100"
>
<TrashIcon />
</button>
)}
</div>
);
}
export default TaskCard;

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axiosapi from '../api/axiosapi'; import axiosapi from '../api/AuthenticationApi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';