Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
510b2c2
feat(calendar): implement calendar functionality with event creation,…
mateocarciu Sep 16, 2026
52932dc
fix: fix files style
mateocarciu Sep 16, 2026
4cc72cc
fix: remove unused Textarea import from TaskEditFormPanel.vue
mateocarciu Sep 16, 2026
4fd59df
fix: apply dark mode to calendar
mateocarciu Sep 18, 2026
02e7b07
feat(calendar): enhance calendar functionality with loading states an…
mateocarciu Sep 20, 2026
9bd3da0
fix(calendar): improve calendar and tasks detail handling and loading…
mateocarciu Sep 20, 2026
1c6e82a
feat(calendar): update event class handling for improved styling and …
mateocarciu Sep 28, 2026
80ce3f4
fix(calendar): refactor calendar handling with new composables
mateocarciu Sep 28, 2026
79fb1bd
feat(notifications): paginate notifications & tests
mateocarciu Sep 28, 2026
c9005f0
fix(notifications): fetch only unread number notifications, then when…
mateocarciu Sep 29, 2026
33dd5fa
fix(calendar): autoscroll to current time when opening week or day view
mateocarciu Sep 29, 2026
88c79fe
feat(calendar): display time with drag functionality and custom event…
mateocarciu Sep 30, 2026
82bfa45
fix(event-dialog): improve loading state handling based on event pres…
mateocarciu Sep 30, 2026
172d9c0
fix(readme): update readme
mateocarciu Oct 2, 2026
16bac13
fix(tags): refactor tag management to settings namespace and update r…
mateocarciu Oct 2, 2026
c179e0d
feat(calendar): add feature tests for calendar events functionality &…
mateocarciu Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

VITE_APP_NAME="${APP_NAME}"

# Jitsi Meet (Video Conferencing)
# Defaults to public instance meet.jit.si if left blank
JITSI_DOMAIN=meet.jit.si
JITSI_APP_ID=
JITSI_APP_SECRET=
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,23 @@ Short demo on YouTube: [Demo](https://youtu.be/Vk0FuN59XUE)
- Detailed activity log timeline for full auditability.
- **Document Uploads:** Attach files directly to tasks (currently supporting Images and PDFs).
- **Smart Link Previews:** Automatic, rich previews for URLs embedded in task descriptions and comments.
- **Interactive Calendar & Scheduling:**
- Full-featured calendar supporting Month, Week, and Day views.
- Drag-and-drop time range selection to quickly create events.
- Event creation and editing with descriptions, date/time pickers, and attendee assignments.
- Team member invitations with RSVP statuses (Accepted, Declined, Pending) and event privacy/hiding controls.
- **Integrated Video Conferencing (Jitsi Meet):**
- One-click video call generation directly linked to scheduled events.
- Dedicated in-app meeting room view with Jitsi Meet integration.
- Support for custom self-hosted (on-premise) instances or automatic fallback to public servers (`meet.jit.si`).
- **Workload Dashboard:** Team workspace overview featuring task metrics, urgent "To Handle Now" tasks, column breakdowns, and a recent activity feed.

## Tech Stack

- **Backend:** Laravel 13 (PHP >= 8.4), Inertia.js V3
- **Frontend:** Vue 3
- **Styling:** Tailwind CSS & Shadcn
- **Video Conferencing:** Jitsi Meet
- **CI/CD:** GitHub Actions workflow for automated testing, linting and deployment.
- **Deployment:** Docker, with a production-ready Dockerfile included in the repository.

Expand Down Expand Up @@ -94,3 +104,21 @@ Taskly ships with a single-tenant role model. Every account holds one of three *
- **Removing & demoting:** privileged users cannot be removed from a team (they always retain access). Demoting an Admin keeps them as an explicit member of the team the demotion happened in, so they don't silently vanish from the member list.
- **New teams** are bootstrapped with default columns (To Do, In Progress, Done), matching the seeder.

## Video Conferencing Setup (Jitsi Meet)

Taskly includes integrated video conferencing for calendar events powered by Jitsi Meet. It works out of the box with zero configuration, while supporting private, on-premise deployments:

- **Public Fallback (Default):** If `JITSI_DOMAIN` is omitted or left empty, Taskly automatically connects to the public instance (`meet.jit.si`). No API keys or extra configuration are needed.
- **Self-Hosted / On-Premise:** If your organization hosts its own Jitsi Meet server, point Taskly to it by setting `JITSI_DOMAIN` to your instance domain (e.g., `meet.yourcompany.com`).
- **JWT Authentication & Moderation:** For self-hosted instances secured with token authentication (JWT), define `JITSI_APP_ID` and `JITSI_APP_SECRET`. Taskly will automatically issue signed HS256 JWT tokens to grant moderator privileges to the event organizer.

Add or update these variables in your `.env` file:

```env
# Jitsi Meet domain (defaults to meet.jit.si if left blank)
JITSI_DOMAIN=meet.jit.si

# Optional: required only for authenticated / self-hosted Jitsi instances
JITSI_APP_ID=
JITSI_APP_SECRET=
```
96 changes: 96 additions & 0 deletions app/Http/Controllers/CalendarController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace App\Http\Controllers;

use App\Http\Requests\EventRespondRequest;
use App\Http\Requests\EventStoreRequest;
use App\Http\Requests\EventUpdateRequest;
use App\Http\Resources\EventIndexResource;
use App\Http\Resources\EventResource;
use App\Models\Event;
use App\Services\CalendarService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class CalendarController extends Controller
{
public function __construct(private CalendarService $calendarService) {}

public function index(Request $request): Response
{
$data = $this->calendarService->indexData(
$request->user(),
$request->query('start'),
$request->query('end'),
);

return Inertia::render('Calendar/Index', [
'events' => Inertia::defer(fn () => EventIndexResource::collection($data['events'])->resolve($request)),
'teamMembers' => $data['teamMembers'],
'view' => $request->query('view'),
'start' => $request->query('start'),
'end' => $request->query('end'),
]);
}

public function show(Request $request, Event $event): JsonResponse
{
$this->authorize('view', $event);

return response()->json(
EventResource::make($this->calendarService->show($event))->resolve($request)
);
}

public function store(EventStoreRequest $request): RedirectResponse
{
$this->calendarService->create($request->validated(), $request->user());

return back();
}

public function update(EventUpdateRequest $request, Event $event): RedirectResponse
{
$this->authorize('update', $event);

$this->calendarService->update($event, $request->validated());

return back();
}

public function destroy(Request $request, Event $event): RedirectResponse
{
$this->authorize('delete', $event);

$this->calendarService->hide($event, $request->user());

return back();
}

public function respond(EventRespondRequest $request, Event $event): RedirectResponse
{
$this->authorize('respond', $event);

$this->calendarService->respond($event, $request->user(), $request->validated('response'));

return back();
}

public function room(Request $request, Event $event): Response
{
$this->authorize('room', $event);

return Inertia::render('Calendar/Room', [
'event' => EventResource::make($event->load('organizer:id,name'))->resolve($request),
'jitsiDomain' => config('services.jitsi.domain'),
'jwt' => $this->calendarService->generateJitsiToken(
$request->user(),
$event->room_name,
$request->user()->id === $event->organizer_id
),
]);
}
}
41 changes: 41 additions & 0 deletions app/Http/Controllers/NotificationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace App\Http\Controllers;

use App\Http\Resources\NotificationResource;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Notifications\DatabaseNotification;

class NotificationController extends Controller
{
public function __construct(private readonly NotificationService $notificationService) {}

public function index(Request $request): JsonResponse
{
return response()->json(
NotificationResource::paginated($this->notificationService->paginateFor($request->user()), $request)
);
}

public function markAsRead(Request $request, DatabaseNotification $notification): RedirectResponse
{
abort_unless(
$notification->notifiable()->whereKey($request->user()->getKey())->exists(),
403
);

$notification->markAsRead();

return back();
}

public function markAllAsRead(Request $request): RedirectResponse
{
$request->user()->unreadNotifications()->update(['read_at' => now()]);

return back();
}
}
8 changes: 4 additions & 4 deletions app/Http/Controllers/TagController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function index(Request $request): Response
{
$tags = $this->tagService->listForTeam($request->user()->team_id);

return Inertia::render('Tags', [
return Inertia::render('settings/Tags', [
'tags' => $tags->through(
fn (Tag $tag) => (new TagResource($tag))->resolve($request)
),
Expand All @@ -31,7 +31,7 @@ public function store(TagStoreRequest $request): RedirectResponse
{
$this->tagService->create($request->validated(), $request->user());

return to_route('tags.index');
return to_route('settings.tags.index');
}

public function update(TagUpdateRequest $request, Tag $tag): RedirectResponse
Expand All @@ -40,7 +40,7 @@ public function update(TagUpdateRequest $request, Tag $tag): RedirectResponse

$this->tagService->update($tag, $request->validated());

return to_route('tags.index');
return to_route('settings.tags.index');
}

public function destroy(Request $request, Tag $tag): RedirectResponse
Expand All @@ -49,6 +49,6 @@ public function destroy(Request $request, Tag $tag): RedirectResponse

$this->tagService->delete($tag);

return to_route('tags.index');
return to_route('settings.tags.index');
}
}
8 changes: 8 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

namespace App\Http\Middleware;

use App\Services\NotificationService;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
public function __construct(private readonly NotificationService $notificationService) {}

/**
* The root template that's loaded on the first page visit.
*
Expand Down Expand Up @@ -50,6 +53,11 @@ public function share(Request $request): array
? $request->user()->accessibleTeamsQuery()->orderBy('name')->get(['id', 'name'])
: [],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'notifications' => fn () => [
'unreadCount' => $request->user()
? $this->notificationService->unreadCountFor($request->user())
: 0,
],
];
}
}
29 changes: 29 additions & 0 deletions app/Http/Requests/Concerns/EventRules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Http\Requests\Concerns;

use Illuminate\Validation\Rule;

trait EventRules
{
/**
* Common validation rules shared by event store and update requests.
*
* @return array<string, array<mixed>>
*/
protected function eventRules(int $teamId): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'start_at' => ['required', 'date'],
'end_at' => ['required', 'date', 'after:start_at'],
'timezone' => ['sometimes', 'string', 'max:50'],
'has_video' => ['sometimes', 'boolean'],
'attendee_ids' => ['sometimes', 'array'],
'attendee_ids.*' => ['integer', Rule::exists('users', 'id')->where(
fn ($query) => $query->where('team_id', $teamId)
)],
];
}
}
31 changes: 31 additions & 0 deletions app/Http/Requests/EventRespondRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class EventRespondRequest extends FormRequest
{
/**
* Pull the response out of the route so it can be validated.
*/
protected function prepareForValidation(): void
{
$this->merge([
'response' => $this->route('response'),
]);
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'response' => ['required', 'in:accepted,declined'],
];
}
}
22 changes: 22 additions & 0 deletions app/Http/Requests/EventStoreRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Http\Requests;

use App\Http\Requests\Concerns\EventRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class EventStoreRequest extends FormRequest
{
use EventRules;

/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return $this->eventRules($this->user()?->team_id);
}
}
25 changes: 25 additions & 0 deletions app/Http/Requests/EventUpdateRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Http\Requests;

use App\Http\Requests\Concerns\EventRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

class EventUpdateRequest extends FormRequest
{
use EventRules;

/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$event = $this->route('event');
$teamId = $event?->calendar?->team_id ?? $this->user()?->team_id;

return $this->eventRules($teamId);
}
}
25 changes: 25 additions & 0 deletions app/Http/Resources/EventIndexResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class EventIndexResource extends JsonResource
{
/**
* Lightweight representation for the calendar grid.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'start_at' => $this->start_at?->toIso8601String(),
'end_at' => $this->end_at?->toIso8601String(),
'has_video' => $this->has_video,
];
}
}
Loading
Loading