Fix: CardDAV sync broke when todo items didn't have a creation date. Also fixed todos without a due date not being displays and wrong calculation of warning badge states.

This commit is contained in:
2026-05-25 15:31:29 +02:00
parent 2324ad95bf
commit da3ceeaff3
5 changed files with 95 additions and 45 deletions
+23 -4
View File
@@ -16,7 +16,8 @@ class CaldavSyncCommand extends Command
public function handle(CaldavService $service) public function handle(CaldavService $service)
{ {
// only run every 5 minutes although the task is called every minute // Throttle execution
// only run every 5 minutes although the scheduler is run every minute
$cacheKey = 'caldav_sync_last_run'; $cacheKey = 'caldav_sync_last_run';
if (\Illuminate\Support\Facades\Cache::has($cacheKey)) { if (\Illuminate\Support\Facades\Cache::has($cacheKey)) {
Log::info('CalDAV sync Throttled'); Log::info('CalDAV sync Throttled');
@@ -29,6 +30,7 @@ public function handle(CaldavService $service)
$todos = $service->getTodos(); $todos = $service->getTodos();
// Update
$count = 0; $count = 0;
foreach ($todos as $todo) { foreach ($todos as $todo) {
// Only update the fields that are present in CalDAV // Only update the fields that are present in CalDAV
@@ -74,12 +76,27 @@ public function handle(CaldavService $service)
} }
} }
Todo::upsert($data, 'id'); // Get the existing todo from the database
$existingTodo = Todo::find($data['id']);
if ($existingTodo) {
// Compare the etag or modification date to determine if the todo needs to be updated
if ($existingTodo->etag !== $data['etag'] || $existingTodo->last_modified < $data['last_modified']) {
// Update the existing todo with the new data
$existingTodo->update($data);
}
} else {
// Create a new todo if it doesn't exist
Todo::create($data);
}
$count++; $count++;
} }
// Collect hrefs/URLs returned by the CalDAV server so we can remove local
// todos that belong to this calendar but were deleted on the server. // Collect hrefs/URLs returned by the CalDAV server so we can compare
// them to local todos and find those that belong to this calendar but
// were deleted on the server.
$hrefs = array_values(array_filter(array_map(function ($t) { $hrefs = array_values(array_filter(array_map(function ($t) {
return $t->url ?? null; return $t->url ?? null;
}, $todos))); }, $todos)));
@@ -98,12 +115,14 @@ public function handle(CaldavService $service)
->delete(); ->delete();
} }
// Remove old todos // Remove old todos
Todo::where('status', 'COMPLETED') Todo::where('status', 'COMPLETED')
->where('last_modified', '<', now()->subDays(30)) // TODO: get from settings ->where('last_modified', '<', now()->subDays(30)) // TODO: get from settings
->where('due_date', '<', now()->subDays(30)) ->where('due_date', '<', now()->subDays(30))
->delete(); ->delete();
Log::info("Synced " . count($todos) . " todos."); Log::info("Synced " . count($todos) . " todos.");
$this->info("Synced " . count($todos) . " todos."); $this->info("Synced " . count($todos) . " todos.");
+6 -3
View File
@@ -19,7 +19,7 @@ public function index()
* Display a listing of the resource. * Display a listing of the resource.
* @param Request $request * @param Request $request
* @param string $modelType The type of the model (e.g., 'Customer', 'Invoice') * @param string $modelType The type of the model (e.g., 'Customer', 'Invoice')
* @param string $modelId The ID of the model * @param int $modelId The ID of the model
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
public function todosForModel(Request $request, string $modelType, int $modelId) public function todosForModel(Request $request, string $modelType, int $modelId)
@@ -27,9 +27,12 @@ public function todosForModel(Request $request, string $modelType, int $modelId)
$model = app("App\\Models\\" . $modelType)::findOrFail($modelId); $model = app("App\\Models\\" . $modelType)::findOrFail($modelId);
// Load all todos of the model with the user relationship // Load all todos of the model with the user relationship
$todos = $model->todos()->with('type')->orderBy('created_at', 'desc')->get(); $todos = $model->todos()
->with('type')
->orderBy('created_at', 'desc')
->get();
// Transformiere die Daten in camelCase // Transform data to camelCase
$notesArray = $todos->map(function ($todo) { $notesArray = $todos->map(function ($todo) {
return ApiDataTransformer::snakeToCamel($todo->toArray()); return ApiDataTransformer::snakeToCamel($todo->toArray());
}); });
+2 -2
View File
@@ -163,8 +163,8 @@ public function getTodos()
if (isset($vcalendar->VTODO->PRIORITY)) $todo->priority = $vcalendar->VTODO->PRIORITY->getValue(); if (isset($vcalendar->VTODO->PRIORITY)) $todo->priority = $vcalendar->VTODO->PRIORITY->getValue();
if (isset($vcalendar->VTODO->STATUS)) $todo->status = $vcalendar->VTODO->STATUS->getValue(); if (isset($vcalendar->VTODO->STATUS)) $todo->status = $vcalendar->VTODO->STATUS->getValue();
if (isset($vcalendar->VTODO->{'RELATED-TO'})) $todo->parent = $vcalendar->VTODO->{'RELATED-TO'}->getValue(); if (isset($vcalendar->VTODO->{'RELATED-TO'})) $todo->parent = $vcalendar->VTODO->{'RELATED-TO'}->getValue();
$todo->created_at = $vcalendar->VTODO->CREATED->getDateTime(); $todo->created_at = $vcalendar->VTODO->CREATED?->getDateTime();
$todo->last_modified = $vcalendar->VTODO->{'LAST-MODIFIED'}->getDateTime(); $todo->last_modified = $vcalendar->VTODO->{'LAST-MODIFIED'}?->getDateTime();
$todos[] = $todo; $todos[] = $todo;
} }
+34 -11
View File
@@ -23,13 +23,15 @@ onMounted(() => {
} }
}) })
// Define a type for the key-value pairs
type GroupedTodos = Array<{ key: string; todos: Todo[] }>;
const groupedTodos = computed(() => { const groupedTodos = computed(() => {
const groups: Record<string, Todo[]> = {}; const groups: Record<string, Todo[]> = {};
if (todos.value) { if (todos.value) {
for (let todo of todos.value) { for (let todo of todos.value) {
if (!todo.dueDate) continue if (todo.dueDate) {
let dueDate = new Date(todo.dueDate) let dueDate = new Date(todo.dueDate)
// today // today
@@ -53,16 +55,37 @@ const groupedTodos = computed(() => {
if (!groups[month]) groups[month] = [] if (!groups[month]) groups[month] = []
groups[month].push(todo) groups[month].push(todo)
} }
} else {
if (!groups['noDueDate']) groups['noDueDate'] = []
groups['noDueDate'].push(todo)
}
} }
} }
return groups // Convert the groupedTodos object to an array of key-value pairs
const entries = Object.entries(groups);
// Sort the entries so that 'noDueDate' is always last
entries.sort((a, b) => {
if (a[0] === 'noDueDate') return 1;
if (b[0] === 'noDueDate') return -1;
return 0;
});
// Convert the sorted array to an array of key-value pairs
const sortedGroupedTodos: GroupedTodos = entries.map(([key, todos]) => ({
key,
todos,
}));
return sortedGroupedTodos;
}) })
const groupNameForKey = (key: string) => { const groupNameForKey = (key: string) => {
if (key === 'today') return 'Heute' if (key === 'today') return 'Heute'
if (key === 'tomorrow') return 'Morgen' if (key === 'tomorrow') return 'Morgen'
if (key === 'overdue') return 'Überfällig' if (key === 'overdue') return 'Überfällig'
if (key === 'noDueDate') return 'Ohne Fälligkeitsdatum'
return key return key
} }
@@ -105,20 +128,20 @@ const shouldDisplay = (todo: Todo) => {
</script> </script>
<template> <template>
<div v-if="todos" v-for="(todos, groupKey) in groupedTodos" :key="groupKey"> <div v-if="todos" v-for="group in groupedTodos">
<div v-if="!todosEmpty(todos)"> <div v-if="!todosEmpty(group.todos)">
<!-- Group header --> <!-- Group header -->
<h3 class="mt-4 mb-2 text-sm text-muted-foreground" :class="{ <h3 class="mt-4 mb-2 text-sm text-muted-foreground" :class="{
'text-destructive! font-bold': groupKey === 'overdue', 'text-destructive! font-bold': group.key === 'overdue',
'text-warning! font-bold': groupKey === 'today' 'text-warning! font-bold': group.key === 'today'
}"> }">
{{ groupNameForKey(groupKey) }} {{ groupNameForKey(group.key) }}
</h3> </h3>
<hr> <hr>
<ul> <ul>
<li v-for="todo in todos" class="flex gap-3 items-baseline py-2.5 pr-1 transition-all" <li v-for="todo in group.todos" class="flex gap-3 items-baseline py-2.5 pr-1 transition-all"
:class="{ 'scale-y-0 h-0 py-0! my-0 origin-top': !shouldDisplay(todo) }"> :class="{ 'scale-y-0 h-0 py-0! my-0 origin-top': !shouldDisplay(todo) }">
<!-- Check mark --> <!-- Check mark -->
@@ -151,8 +174,8 @@ const shouldDisplay = (todo: Todo) => {
}} }}
</Badge> </Badge>
<span v-if="todo.dueDate" :class="{ <span v-if="todo.dueDate" :class="{
'text-destructive! font-bold': groupKey === 'overdue', 'text-destructive! font-bold': group.key === 'overdue',
'text-warning! font-bold': groupKey === 'today' 'text-warning! font-bold': group.key === 'today'
}"> }">
{{ toDuration(todo.dueDate) }}</span> {{ toDuration(todo.dueDate) }}</span>
<Repeat v-if="todo.recurring" stroke-width="2" :size="14" /> <Repeat v-if="todo.recurring" stroke-width="2" :size="14" />
+8 -3
View File
@@ -22,7 +22,6 @@ import TodoService from '@/services/TodoService'
import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue'; import NumberInput from '@/components/ui/crm-number-input/NumberInput.vue';
import { alertStore } from '@/stores/alertStore' import { alertStore } from '@/stores/alertStore'
import PipelineService from '@/services/PipelineService' import PipelineService from '@/services/PipelineService'
import { cva } from "class-variance-authority"
interface Props { interface Props {
pipeline: PipelineLane[] pipeline: PipelineLane[]
@@ -144,6 +143,12 @@ const badgeVariant = (date: string | null): "default" | "secondary" | "destructi
return "secondary" return "secondary"
} }
const itemsNextTodoDueDate = (item: PipelineItem): string | null => {
if (!item.todos || item.todos.length === 0) return null
const next = item.todos.find(t => t.status.toLowerCase() !== 'completed')
return next?.dueDate || null
}
const editItem = async (item: PipelineItem) => { const editItem = async (item: PipelineItem) => {
// Load todos lazily // Load todos lazily
if (item.id !== 0 && (item.todos === undefined || item.todos.length === 0)) { if (item.id !== 0 && (item.todos === undefined || item.todos.length === 0)) {
@@ -287,7 +292,7 @@ const saveItem = (item: PipelineItem | undefined) => {
</Badge> </Badge>
<Badge v-if="item.todos && item.todos.length > 0" <Badge v-if="item.todos && item.todos.length > 0"
:variant="badgeVariant(item.todos[item.todos.length - 1]?.dueDate || null)"> :variant="badgeVariant(itemsNextTodoDueDate(item))">
<SquareCheckBig /> {{item.todos.filter(todo => todo.status.toLowerCase() !== <SquareCheckBig /> {{item.todos.filter(todo => todo.status.toLowerCase() !==
'completed').length}} 'completed').length}}
</Badge> </Badge>
@@ -351,7 +356,7 @@ const saveItem = (item: PipelineItem | undefined) => {
<template v-slot:sidebar> <template v-slot:sidebar>
<NumberInput label="Erwarteter Umsatz" :modelValue="selectedItem?.expectedRevenue as number" suffix=" " <NumberInput label="Erwarteter Umsatz" :modelValue="selectedItem?.expectedRevenue as number" suffix=" "
@update:model-value="console.log" /> @update:model-value="console.log" />
<Todos v-if="selectedItem" title="Aufgaben" :modelValue="selectedItem.todos" :show-completed="false" /> <Todos v-if="selectedItem" :modelValue="selectedItem.todos" :show-completed="false" />
</template> </template>
</EditorDialog> </EditorDialog>