add vikunja integration

This commit is contained in:
Matthias Hochmeister
2026-03-05 18:07:18 +01:00
parent fb5acd3d52
commit e9463c1c66
13 changed files with 683 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Divider,
Skeleton,
Chip,
} from '@mui/material';
import { AssignmentInd } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { format, isPast } from 'date-fns';
import { de } from 'date-fns/locale';
import { vikunjaApi } from '../../services/vikunja';
import type { VikunjaTask } from '../../types/vikunja.types';
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
0: { label: 'Keine', color: 'default' },
1: { label: 'Niedrig', color: 'default' },
2: { label: 'Mittel', color: 'default' },
3: { label: 'Hoch', color: 'warning' },
4: { label: 'Kritisch', color: 'error' },
5: { label: 'Dringend', color: 'error' },
};
const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: string }> = ({
task,
showDivider,
vikunjaUrl,
}) => {
const handleClick = () => {
window.open(`${vikunjaUrl}/tasks/${task.id}`, '_blank', 'noopener,noreferrer');
};
const dueDateStr = task.due_date
? format(new Date(task.due_date), 'dd. MMM', { locale: de })
: null;
const overdue = task.due_date ? isPast(new Date(task.due_date)) : false;
const priority = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0];
return (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{task.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{dueDateStr && (
<Typography
variant="caption"
color={overdue ? 'error' : 'text.secondary'}
>
{dueDateStr}
</Typography>
)}
{task.priority > 0 && (
<Chip
label={priority.label}
size="small"
color={priority.color}
sx={{ height: 18, fontSize: '0.65rem' }}
/>
)}
</Box>
</Box>
</Box>
{showDivider && <Divider />}
</>
);
};
const VikunjaMyTasksWidget: React.FC = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ['vikunja-my-tasks'],
queryFn: () => vikunjaApi.getMyTasks(),
refetchInterval: 5 * 60 * 1000,
retry: 1,
});
const configured = data?.configured ?? true;
const tasks = data?.data ?? [];
if (!configured) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AssignmentInd color="disabled" />
<Typography variant="h6" color="text.secondary">
Meine Aufgaben
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht eingerichtet
</Typography>
</CardContent>
</Card>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AssignmentInd color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Meine Aufgaben
</Typography>
{!isLoading && !isError && tasks.length > 0 && (
<Chip label={tasks.length} size="small" color="primary" />
)}
</Box>
{isLoading && (
<Box>
{[1, 2, 3].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="40%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht erreichbar
</Typography>
)}
{!isLoading && !isError && tasks.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine offenen Aufgaben
</Typography>
)}
{!isLoading && !isError && tasks.length > 0 && (
<Box>
{tasks.map((task, index) => (
<TaskRow
key={task.id}
task={task}
showDivider={index < tasks.length - 1}
vikunjaUrl={import.meta.env.VITE_VIKUNJA_URL ?? ''}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default VikunjaMyTasksWidget;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { vikunjaApi } from '../../services/vikunja';
/**
* Invisible component — polls /api/vikunja/overdue every 10 minutes.
* The backend creates notifications as a side-effect when polled.
*/
const VikunjaOverdueNotifier: React.FC = () => {
useQuery({
queryKey: ['vikunja-overdue'],
queryFn: () => vikunjaApi.getOverdueTasks(),
refetchInterval: 10 * 60 * 1000,
retry: 1,
});
return null;
};
export default VikunjaOverdueNotifier;

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Button,
MenuItem,
Select,
FormControl,
InputLabel,
Skeleton,
SelectChangeEvent,
} from '@mui/material';
import { AddTask } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { vikunjaApi } from '../../services/vikunja';
import { useNotification } from '../../contexts/NotificationContext';
const VikunjaQuickAddWidget: React.FC = () => {
const [title, setTitle] = useState('');
const [projectId, setProjectId] = useState<number | ''>('');
const [dueDate, setDueDate] = useState('');
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const { data: projectsData, isLoading: projectsLoading } = useQuery({
queryKey: ['vikunja-projects'],
queryFn: () => vikunjaApi.getProjects(),
refetchInterval: 10 * 60 * 1000,
retry: 1,
});
const configured = projectsData?.configured ?? true;
const projects = projectsData?.data ?? [];
const mutation = useMutation({
mutationFn: () =>
vikunjaApi.createTask(
projectId as number,
title,
dueDate || undefined,
),
onSuccess: () => {
showSuccess('Aufgabe erstellt');
setTitle('');
setDueDate('');
setProjectId('');
queryClient.invalidateQueries({ queryKey: ['vikunja-my-tasks'] });
},
onError: () => {
showError('Aufgabe konnte nicht erstellt werden');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || projectId === '') return;
mutation.mutate();
};
if (!configured) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AddTask color="disabled" />
<Typography variant="h6" color="text.secondary">
Aufgabe erstellen
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht eingerichtet
</Typography>
</CardContent>
</Card>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AddTask color="primary" />
<Typography variant="h6">Aufgabe erstellen</Typography>
</Box>
{projectsLoading ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth size="small">
<InputLabel>Projekt</InputLabel>
<Select
value={projectId}
label="Projekt"
onChange={(e: SelectChangeEvent<number | ''>) =>
setProjectId(e.target.value as number | '')
}
>
{projects.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.title}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
size="small"
label="Titel"
value={title}
onChange={(e) => setTitle(e.target.value)}
inputProps={{ maxLength: 250 }}
/>
<TextField
fullWidth
size="small"
label="Fälligkeitsdatum (optional)"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!title.trim() || projectId === '' || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
);
};
export default VikunjaQuickAddWidget;

View File

@@ -6,3 +6,6 @@ export { default as PersonalWarningsBanner } from './PersonalWarningsBanner';
export { default as UpcomingEventsWidget } from './UpcomingEventsWidget';
export { default as BookStackRecentWidget } from './BookStackRecentWidget';
export { default as BookStackSearchWidget } from './BookStackSearchWidget';
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';