feat: Aufgaben (Tasks) mit CalDAV VTODO-Sync

Neuer Menuepunkt "Aufgaben" unterhalb Kontakte.

Backend:
- TaskList + Task + TaskListShare Models
- REST-API: CRUD, Teilen, my-color, Import/Export (.ics mit VTODO, CSV)
- CalDAV: Task-Listen tauchen als Calendar-Collection mit
  supported-calendar-component-set=VTODO im autodiscovery auf
- PROPFIND/REPORT/GET/PUT/DELETE/PROPPATCH/MKCOL fuer /dav/<user>/tl-<id>/
- SSE-Notifications bei Aenderungen

Frontend:
- TasksView mit Listen-Sidebar, Suche, "Erledigte ausblenden"
- Mehrfachauswahl + Bulk-Loeschen, Status-Toggle per Checkbox
- Editor mit Titel/Beschreibung/Faellig/Prioritaet/Status/Fortschritt
- Teilen, Farbe persoenlich anpassen, Import/Export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-14 15:07:06 +02:00
parent 2ce088e96b
commit ba3e619963
10 changed files with 1727 additions and 5 deletions
+5
View File
@@ -48,6 +48,11 @@ const routes = [
name: 'Contacts',
component: () => import('../views/ContactsView.vue'),
},
{
path: 'tasks',
name: 'Tasks',
component: () => import('../views/TasksView.vue'),
},
{
path: 'email',
name: 'Email',
+5
View File
@@ -22,6 +22,11 @@
<span>Kontakte</span>
</router-link>
<router-link to="/tasks" class="nav-item" active-class="active">
<i class="pi pi-check-square"></i>
<span>Aufgaben</span>
</router-link>
<router-link
v-if="auth.hasEmailAccounts"
to="/email"
+632
View File
@@ -0,0 +1,632 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Aufgaben</h2>
<div class="header-actions">
<Button icon="pi pi-list" label="Neue Liste" size="small" outlined @click="showNewList = true" />
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="triggerImport" />
<input ref="importInput" type="file" accept=".ics,.ical,.csv" hidden @change="onImportFile" />
<Button icon="pi pi-download" label="Export" size="small" outlined
:disabled="!selectedListId" @click="showExportDialog = true" />
<Button icon="pi pi-plus" label="Neue Aufgabe" size="small"
:disabled="!selectedListId" @click="openNewTask" />
</div>
</div>
<div class="tasks-layout">
<aside class="lists-sidebar">
<h4>Listen</h4>
<div v-for="tl in lists" :key="tl.id"
class="list-item" :class="{ active: selectedListId === tl.id }"
@click="selectedListId = tl.id">
<span class="list-color" :style="{ background: tl.color }"></span>
<span class="list-name">{{ tl.name }}</span>
<span v-if="tl.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span class="count">{{ tl.task_count }}</span>
<Button icon="pi pi-ellipsis-v" text size="small" class="list-menu"
@click.stop="openListMenu(tl)" />
</div>
</aside>
<div class="tasks-main">
<div class="toolbar">
<InputText v-model="search" placeholder="Aufgaben suchen..." fluid />
<label class="toggle"><Checkbox v-model="hideDone" :binary="true" /> Erledigte ausblenden</label>
</div>
<div v-if="selectedTaskIds.length" class="bulk-bar">
<span>{{ selectedTaskIds.length }} ausgewaehlt</span>
<Button icon="pi pi-trash" :label="`${selectedTaskIds.length} loeschen`"
severity="danger" size="small" @click="bulkDelete" />
<Button label="Auswahl aufheben" size="small" text @click="selectedTaskIds = []" />
</div>
<table class="task-table">
<thead>
<tr>
<th class="col-check">
<Checkbox v-model="allSelected" :binary="true" @change="toggleAll" />
</th>
<th class="col-done"></th>
<th>Titel</th>
<th>Faellig</th>
<th>Prio</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in filteredTasks" :key="t.id" class="task-row"
:class="{ done: t.status === 'COMPLETED', selected: selectedTaskIds.includes(t.id) }"
@click="openEditTask(t)">
<td class="col-check" @click.stop>
<Checkbox :modelValue="selectedTaskIds.includes(t.id)" :binary="true"
@update:modelValue="toggleSelect(t.id, $event)" />
</td>
<td class="col-done" @click.stop>
<Checkbox :modelValue="t.status === 'COMPLETED'" :binary="true"
@update:modelValue="toggleDone(t, $event)" title="Erledigt" />
</td>
<td class="col-title">
<span>{{ t.summary || '(ohne Titel)' }}</span>
<small v-if="t.description" class="meta">{{ shortDesc(t.description) }}</small>
</td>
<td class="col-date">{{ formatDue(t.due) }}</td>
<td>{{ formatPrio(t.priority) }}</td>
<td><span class="status-badge" :class="statusClass(t.status)">{{ statusLabel(t.status) }}</span></td>
<td class="col-actions" @click.stop>
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDelete(t)" />
</td>
</tr>
<tr v-if="!filteredTasks.length">
<td colspan="7" class="empty-row">Keine Aufgaben.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- New List Dialog -->
<Dialog v-model:visible="showNewList" header="Neue Aufgabenliste" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newListName" fluid autofocus @keyup.enter="createList" />
</div>
<div class="field">
<label>Farbe</label>
<InputText v-model="newListColor" type="color" style="width: 60px; height: 36px" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewList = false" />
<Button label="Erstellen" @click="createList" />
</template>
</Dialog>
<!-- List Menu -->
<Dialog v-model:visible="showListMenu" header="Listen-Optionen" modal :style="{ width: '480px' }">
<div v-if="menuList">
<p><strong>{{ menuList.name }}</strong></p>
<div class="field">
<label>Farbe</label>
<InputText :modelValue="menuList.color" @change="onListColor($event)" type="color" style="width:60px; height:36px" />
</div>
<div v-if="menuList.permission === 'owner'" class="field">
<label>Mit Benutzer teilen</label>
<div class="share-row">
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="doShare" />
</div>
<div v-if="listShares.length" class="existing-shares">
<div v-for="s in listShares" :key="s.id" class="share-perm-item">
<i class="pi pi-user"></i> <span>{{ s.username }}</span>
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(s.id)" />
</div>
</div>
</div>
<div v-if="menuList.permission === 'owner'" class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
<Button label="Liste loeschen" severity="danger" outlined size="small" @click="confirmDeleteList = true" />
</div>
<div class="field" style="border-top:1px solid var(--p-surface-200); padding-top:1rem">
<label><i class="pi pi-info-circle"></i> CalDAV-Zugang (Handy / DAVx5)</label>
<div class="caldav-hint">In DAVx5 unter demselben Konto sichtbar wie Kalender. Aufgabenlisten sind mit "OpenTasks" synchronisierbar.</div>
<div class="url-row">
<strong>Listen-URL:</strong>
<code>{{ origin }}/dav/{{ username }}/tl-{{ menuList.id }}/</code>
<Button icon="pi pi-copy" text size="small" @click="copy(`${origin}/dav/${username}/tl-${menuList.id}/`)" />
</div>
</div>
</div>
</Dialog>
<!-- Task Dialog -->
<Dialog v-model:visible="showTaskDialog" :header="editingTaskId ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'"
modal :style="{ width: '560px' }">
<div class="field">
<label>Titel</label>
<InputText v-model="taskForm.summary" fluid autofocus />
</div>
<div class="field">
<label>Beschreibung</label>
<Textarea v-model="taskForm.description" rows="3" fluid />
</div>
<div class="field-row">
<div class="field">
<label>Faellig</label>
<InputText v-model="taskForm.due" type="datetime-local" fluid />
</div>
<div class="field">
<label>Status</label>
<Select v-model="taskForm.status" :options="statusOptions" optionLabel="label" optionValue="value" fluid />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Prioritaet</label>
<Select v-model="taskForm.priority" :options="prioOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div class="field">
<label>Fortschritt %</label>
<InputText v-model.number="taskForm.percent_complete" type="number" min="0" max="100" fluid />
</div>
</div>
<div class="field">
<label>Kategorien (kommagetrennt)</label>
<InputText v-model="taskForm.categories" fluid />
</div>
<template #footer>
<Button v-if="editingTaskId" label="Loeschen" text severity="danger" @click="deleteCurrent" />
<Button label="Abbrechen" text @click="showTaskDialog = false" />
<Button :label="editingTaskId ? 'Speichern' : 'Erstellen'" @click="saveTask" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteList" header="Liste loeschen" modal :style="{ width: '400px' }">
<p>Liste <strong>{{ menuList?.name }}</strong> mit allen Aufgaben loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteList = false" />
<Button label="Loeschen" severity="danger" @click="deleteList" />
</template>
</Dialog>
<!-- Export Dialog -->
<Dialog v-model:visible="showExportDialog" header="Aufgaben exportieren" modal :style="{ width: '420px' }">
<p>Aus Liste <strong>{{ currentList?.name }}</strong></p>
<div class="field">
<label>Format</label>
<Select v-model="exportFormat" :options="exportFormats" optionLabel="label" optionValue="value" fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showExportDialog = false" />
<Button label="Herunterladen" icon="pi pi-download" @click="doExport" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import Checkbox from 'primevue/checkbox'
const toast = useToast()
const auth = useAuthStore()
const origin = computed(() => window.location.origin)
const username = computed(() => auth.user?.username || '')
const lists = ref([])
const selectedListId = ref(null)
const tasks = ref([])
const search = ref('')
const hideDone = ref(false)
const selectedTaskIds = ref([])
const showNewList = ref(false)
const newListName = ref('')
const newListColor = ref('#10b981')
const showListMenu = ref(false)
const menuList = ref(null)
const shareUsername = ref('')
const sharePermission = ref('read')
const listShares = ref([])
const permOptions = [
{ label: 'Lesen', value: 'read' },
{ label: 'Lesen+Schreiben', value: 'readwrite' },
]
const confirmDeleteList = ref(false)
const showTaskDialog = ref(false)
const editingTaskId = ref(null)
const taskForm = reactive({
summary: '', description: '',
due: '', status: 'NEEDS-ACTION', priority: null, percent_complete: null,
categories: '',
})
const statusOptions = [
{ label: 'Offen', value: 'NEEDS-ACTION' },
{ label: 'In Arbeit', value: 'IN-PROCESS' },
{ label: 'Erledigt', value: 'COMPLETED' },
{ label: 'Abgebrochen', value: 'CANCELLED' },
]
const prioOptions = [
{ label: '—', value: null },
{ label: 'Hoch (1)', value: 1 },
{ label: 'Mittel (5)', value: 5 },
{ label: 'Niedrig (9)', value: 9 },
]
const showExportDialog = ref(false)
const exportFormat = ref('ics')
const exportFormats = [
{ label: 'iCalendar (.ics)', value: 'ics' },
{ label: 'CSV (.csv)', value: 'csv' },
]
const importInput = ref(null)
const currentList = computed(() => lists.value.find(l => l.id === selectedListId.value))
const filteredTasks = computed(() => {
const q = search.value.trim().toLowerCase()
return tasks.value.filter(t => {
if (hideDone.value && t.status === 'COMPLETED') return false
if (q && !(t.summary || '').toLowerCase().includes(q)
&& !(t.description || '').toLowerCase().includes(q)) return false
return true
})
})
const allSelected = computed({
get: () => filteredTasks.value.length > 0 && filteredTasks.value.every(t => selectedTaskIds.value.includes(t.id)),
set: () => {},
})
function toggleAll() {
const ids = filteredTasks.value.map(t => t.id)
const allSel = ids.every(id => selectedTaskIds.value.includes(id))
if (allSel) selectedTaskIds.value = selectedTaskIds.value.filter(id => !ids.includes(id))
else {
const set = new Set(selectedTaskIds.value); ids.forEach(id => set.add(id))
selectedTaskIds.value = [...set]
}
}
function toggleSelect(id, checked) {
if (checked && !selectedTaskIds.value.includes(id)) selectedTaskIds.value = [...selectedTaskIds.value, id]
else if (!checked) selectedTaskIds.value = selectedTaskIds.value.filter(x => x !== id)
}
function shortDesc(s) { return s.length > 80 ? s.slice(0, 80) + '…' : s }
function formatDue(d) {
if (!d) return ''
return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function formatPrio(p) {
if (p === null || p === undefined) return ''
if (p <= 3) return 'Hoch'
if (p >= 7) return 'Niedrig'
return 'Mittel'
}
function statusLabel(s) {
return ({ 'NEEDS-ACTION': 'Offen', 'IN-PROCESS': 'In Arbeit', 'COMPLETED': 'Erledigt', 'CANCELLED': 'Abgebrochen' })[s] || 'Offen'
}
function statusClass(s) {
return { 'NEEDS-ACTION': 'todo', 'IN-PROCESS': 'progress', 'COMPLETED': 'done', 'CANCELLED': 'cancelled' }[s] || 'todo'
}
async function loadLists() {
const res = await apiClient.get('/tasklists')
lists.value = res.data
if (!selectedListId.value && lists.value.length) selectedListId.value = lists.value[0].id
if (!lists.value.length) {
await apiClient.post('/tasklists', { name: 'Meine Aufgaben', color: '#10b981' })
await loadLists()
}
}
async function loadTasks() {
if (!selectedListId.value) { tasks.value = []; return }
try {
const res = await apiClient.get(`/tasklists/${selectedListId.value}/tasks`)
tasks.value = res.data
} catch { tasks.value = [] }
}
async function createList() {
if (!newListName.value.trim()) return
await apiClient.post('/tasklists', { name: newListName.value.trim(), color: newListColor.value })
showNewList.value = false
newListName.value = ''
await loadLists()
}
function openListMenu(tl) {
menuList.value = tl
shareUsername.value = ''
showListMenu.value = true
loadShares()
}
async function loadShares() {
if (!menuList.value || menuList.value.permission !== 'owner') { listShares.value = []; return }
try {
const res = await apiClient.get(`/tasklists/${menuList.value.id}/shares`)
listShares.value = res.data
} catch { listShares.value = [] }
}
async function doShare() {
if (!menuList.value || !shareUsername.value.trim()) return
try {
await apiClient.post(`/tasklists/${menuList.value.id}/share`, {
username: shareUsername.value.trim(), permission: sharePermission.value,
})
toast.add({ severity: 'success', summary: 'Geteilt', life: 2500 })
shareUsername.value = ''
await loadShares()
} catch (err) {
toast.add({ severity: 'error', summary: err.response?.data?.error || 'Fehler', life: 4000 })
}
}
async function removeShare(id) {
await apiClient.delete(`/tasklists/${menuList.value.id}/shares/${id}`)
await loadShares()
}
async function onListColor(ev) {
const color = ev.target.value
await apiClient.put(`/tasklists/${menuList.value.id}/my-color`, { color })
menuList.value.color = color
await loadLists()
}
async function deleteList() {
if (!menuList.value) return
await apiClient.delete(`/tasklists/${menuList.value.id}`)
confirmDeleteList.value = false
showListMenu.value = false
if (selectedListId.value === menuList.value.id) selectedListId.value = null
await loadLists()
await loadTasks()
}
function openNewTask() {
editingTaskId.value = null
Object.assign(taskForm, {
summary: '', description: '', due: '',
status: 'NEEDS-ACTION', priority: null, percent_complete: null,
categories: '',
})
showTaskDialog.value = true
}
function openEditTask(t) {
editingTaskId.value = t.id
Object.assign(taskForm, {
summary: t.summary || '',
description: t.description || '',
due: t.due ? t.due.slice(0, 16) : '',
status: t.status || 'NEEDS-ACTION',
priority: t.priority,
percent_complete: t.percent_complete,
categories: (t.categories || []).join(', '),
})
showTaskDialog.value = true
}
async function saveTask() {
if (!taskForm.summary.trim()) return
const payload = {
summary: taskForm.summary.trim(),
description: taskForm.description,
due: taskForm.due ? new Date(taskForm.due).toISOString() : null,
status: taskForm.status,
priority: taskForm.priority,
percent_complete: taskForm.percent_complete,
categories: taskForm.categories.split(',').map(s => s.trim()).filter(Boolean),
}
try {
if (editingTaskId.value) {
await apiClient.put(`/tasks/${editingTaskId.value}`, payload)
} else {
await apiClient.post(`/tasklists/${selectedListId.value}/tasks`, payload)
}
showTaskDialog.value = false
await loadLists()
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
}
}
async function toggleDone(t, checked) {
try {
await apiClient.put(`/tasks/${t.id}`, { status: checked ? 'COMPLETED' : 'NEEDS-ACTION' })
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', life: 3000 })
}
}
async function deleteCurrent() {
if (!editingTaskId.value) return
if (!confirm('Aufgabe wirklich loeschen?')) return
await apiClient.delete(`/tasks/${editingTaskId.value}`)
showTaskDialog.value = false
await loadLists()
await loadTasks()
}
async function confirmDelete(t) {
if (!confirm(`"${t.summary || '(ohne Titel)'}" loeschen?`)) return
await apiClient.delete(`/tasks/${t.id}`)
await loadLists()
await loadTasks()
}
async function bulkDelete() {
const ids = [...selectedTaskIds.value]
if (!ids.length || !confirm(`${ids.length} Aufgabe(n) loeschen?`)) return
let ok = 0, fail = 0
for (const id of ids) {
try { await apiClient.delete(`/tasks/${id}`); ok++ } catch { fail++ }
}
selectedTaskIds.value = []
toast.add({
severity: fail ? 'warn' : 'success',
summary: `${ok} geloescht${fail ? `, ${fail} fehlgeschlagen` : ''}`, life: 3000,
})
await loadLists()
await loadTasks()
}
function triggerImport() {
if (!selectedListId.value) {
toast.add({ severity: 'warn', summary: 'Keine Liste ausgewaehlt', life: 3000 })
return
}
importInput.value?.click()
}
async function onImportFile(ev) {
const file = ev.target.files?.[0]
ev.target.value = ''
if (!file) return
const fd = new FormData()
fd.append('file', file)
try {
const res = await apiClient.post(`/tasklists/${selectedListId.value}/import`, fd,
{ headers: { 'Content-Type': 'multipart/form-data' } })
toast.add({
severity: 'success',
summary: `${res.data.imported} importiert`,
detail: res.data.skipped ? `${res.data.skipped} uebersprungen` : undefined,
life: 4000,
})
await loadLists()
await loadTasks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import fehlgeschlagen', detail: err.response?.data?.error, life: 5000 })
}
}
async function doExport() {
if (!selectedListId.value) return
try {
const res = await apiClient.get(`/tasklists/${selectedListId.value}/export`,
{ params: { format: exportFormat.value }, responseType: 'blob' })
const ext = exportFormat.value === 'csv' ? 'csv' : 'ics'
const url = URL.createObjectURL(new Blob([res.data]))
const a = document.createElement('a')
a.href = url
a.download = `${currentList.value?.name || 'aufgaben'}.${ext}`
a.click()
URL.revokeObjectURL(url)
showExportDialog.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Export fehlgeschlagen', life: 4000 })
}
}
function copy(text) {
navigator.clipboard.writeText(text)
toast.add({ severity: 'info', summary: 'Kopiert', life: 1500 })
}
// --- Live refresh via SSE ---
let eventSource = null
let reloadTimer = null
function scheduleReload() {
if (reloadTimer) return
reloadTimer = setTimeout(async () => {
reloadTimer = null
await loadLists()
await loadTasks()
}, 300)
}
onMounted(async () => {
await loadLists()
await loadTasks()
if (auth.accessToken) {
try {
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
eventSource.addEventListener('tasklist', scheduleReload)
eventSource.addEventListener('message', scheduleReload)
eventSource.onerror = () => {}
} catch {}
}
})
onUnmounted(() => {
if (reloadTimer) clearTimeout(reloadTimer)
if (eventSource) eventSource.close()
})
watch(selectedListId, loadTasks)
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.tasks-layout { display: flex; gap: 1rem; align-items: flex-start; }
.lists-sidebar { width: 260px; flex-shrink: 0; }
.lists-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
.list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-radius: 4px;
cursor: pointer; font-size: 0.875rem; }
.list-item:hover { background: var(--p-surface-50); }
.list-item.active { background: var(--p-primary-50); }
.list-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.list-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
.count { color: var(--p-text-muted-color); font-size: 0.8rem; }
.list-menu { opacity: 0; transition: opacity .15s; }
.list-item:hover .list-menu { opacity: 1; }
.tasks-main { flex: 1; min-width: 0; }
.toolbar { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; }
.toggle { display: flex; align-items: center; gap: 0.35rem; font-size: 0.875rem; white-space: nowrap; }
.bulk-bar { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem 0.75rem;
background: var(--p-primary-50); border-radius: 6px; margin-bottom: 0.5rem; font-size: 0.875rem; }
.task-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.task-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--p-surface-200); font-weight: 600; }
.task-table td { padding: 0.5rem; border-bottom: 1px solid var(--p-surface-100); vertical-align: top; }
.task-row { cursor: pointer; }
.task-row:hover { background: var(--p-surface-50); }
.task-row.done .col-title span { text-decoration: line-through; color: var(--p-text-muted-color); }
.task-row.selected { background: var(--p-primary-50); }
.col-check, .col-done { width: 36px; }
.col-actions { width: 60px; text-align: right; }
.col-date { white-space: nowrap; }
.col-title { }
.meta { display: block; color: var(--p-text-muted-color); font-size: 0.75rem; margin-top: 0.1rem; }
.empty-row { text-align: center; color: var(--p-text-muted-color); padding: 2rem !important; }
.status-badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.72rem; }
.status-badge.todo { background: var(--p-surface-100); }
.status-badge.progress { background: var(--p-blue-100); color: var(--p-blue-700); }
.status-badge.done { background: var(--p-green-100); color: var(--p-green-700); }
.status-badge.cancelled { background: var(--p-red-100); color: var(--p-red-700); }
.field { margin-bottom: 0.75rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
.field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; }
.share-row { display: flex; gap: 0.5rem; align-items: center; }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.url-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.url-row strong { min-width: 110px; font-size: 0.8rem; }
.url-row code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; flex: 1; word-break: break-all; }
.caldav-hint { font-size: 0.8rem; color: var(--p-text-muted-color); margin: 0 0 0.5rem; }
</style>