feat: Phase 4 — Wetter-Widget, Wiederkehrende Aufgaben, Kanban-Ansicht, PWA
- server/routes/weather.js: OpenWeatherMap-Proxy (aktuelles Wetter + 3-Tage-Forecast, 30-min-Cache, graceful fallback wenn kein API-Key gesetzt) - public/pages/dashboard.js: Weather-Widget parallel mit Dashboard-Daten laden - public/styles/dashboard.css: Weather-Widget-Styles (Gradient, Forecast-Strip) - server/services/recurrence.js: RRULE-Parser (FREQ=DAILY/WEEKLY/MONTHLY, BYDAY, INTERVAL, UNTIL) + nextOccurrence()-Funktion - server/routes/tasks.js: Bei PATCH /:id/status = done → nächste Instanz wiederkehrender Aufgaben automatisch anlegen - public/pages/tasks.js: Kanban-Ansicht (3 Spalten: Offen/In Bearbeitung/Erledigt) mit HTML5 Drag & Drop, View-Toggle (Liste/Kanban) - public/styles/tasks.css: Kanban-Board-Styles (Spalten, Cards, Drag-over-Highlight) - public/sw.js: Cache-Version auf v2, alle Modul-CSS-Dateien im APP_SHELL-Cache Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -228,6 +228,54 @@ function renderPinnedNotes(notes) {
|
|||||||
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
|
return `<div class="widget">${header}<div class="widget__body">${items}</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Wetter-Widget
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const WEATHER_ICON_BASE = 'https://openweathermap.org/img/wn/';
|
||||||
|
|
||||||
|
function weatherIconUrl(icon) {
|
||||||
|
return `${WEATHER_ICON_BASE}${icon}@2x.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeatherWidget(weather) {
|
||||||
|
if (!weather) return ''; // Kein API-Key → Widget ausblenden
|
||||||
|
|
||||||
|
const { city, current, forecast } = weather;
|
||||||
|
|
||||||
|
const forecastHtml = forecast.map((d) => {
|
||||||
|
const date = new Date(d.date + 'T12:00:00');
|
||||||
|
const label = date.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||||
|
return `
|
||||||
|
<div class="weather-forecast__day">
|
||||||
|
<div class="weather-forecast__label">${label}</div>
|
||||||
|
<img class="weather-forecast__icon" src="${weatherIconUrl(d.icon)}"
|
||||||
|
alt="${d.desc}" width="32" height="32" loading="lazy">
|
||||||
|
<div class="weather-forecast__temps">
|
||||||
|
<span class="weather-forecast__high">${d.temp_max}°</span>
|
||||||
|
<span class="weather-forecast__low">${d.temp_min}°</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="widget weather-widget">
|
||||||
|
<div class="weather-widget__main">
|
||||||
|
<div class="weather-widget__left">
|
||||||
|
<div class="weather-widget__temp">${current.temp}°C</div>
|
||||||
|
<div class="weather-widget__desc">${current.desc}</div>
|
||||||
|
<div class="weather-widget__city">${city}</div>
|
||||||
|
<div class="weather-widget__meta">
|
||||||
|
Gefühlt ${current.feels_like}° · ${current.humidity}% Luftfeuchtigkeit · Wind ${current.wind_speed} km/h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img class="weather-widget__icon" src="${weatherIconUrl(current.icon)}"
|
||||||
|
alt="${current.desc}" width="80" height="80" loading="lazy">
|
||||||
|
</div>
|
||||||
|
${forecast.length ? `<div class="weather-forecast">${forecastHtml}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// FAB Speed-Dial
|
// FAB Speed-Dial
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -336,10 +384,16 @@ export async function render(container, { user }) {
|
|||||||
`;
|
`;
|
||||||
initFab(container);
|
initFab(container);
|
||||||
|
|
||||||
// Daten laden
|
// Daten laden (Dashboard + Wetter parallel)
|
||||||
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] };
|
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] };
|
||||||
|
let weather = null;
|
||||||
try {
|
try {
|
||||||
data = await api.get('/dashboard');
|
const [dashRes, weatherRes] = await Promise.all([
|
||||||
|
api.get('/dashboard'),
|
||||||
|
api.get('/weather').catch(() => ({ data: null })),
|
||||||
|
]);
|
||||||
|
data = dashRes;
|
||||||
|
weather = weatherRes.data ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] Ladefehler:', err.message);
|
console.error('[Dashboard] Ladefehler:', err.message);
|
||||||
window.oikos?.showToast('Dashboard konnte nicht vollständig geladen werden.', 'warning');
|
window.oikos?.showToast('Dashboard konnte nicht vollständig geladen werden.', 'warning');
|
||||||
@@ -350,6 +404,7 @@ export async function render(container, { user }) {
|
|||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<div class="dashboard__grid">
|
<div class="dashboard__grid">
|
||||||
${renderGreeting(user)}
|
${renderGreeting(user)}
|
||||||
|
${renderWeatherWidget(weather)}
|
||||||
${renderUrgentTasks(data.urgentTasks ?? [])}
|
${renderUrgentTasks(data.urgentTasks ?? [])}
|
||||||
${renderUpcomingEvents(data.upcomingEvents ?? [])}
|
${renderUpcomingEvents(data.upcomingEvents ?? [])}
|
||||||
${renderTodayMeals(data.todayMeals ?? [])}
|
${renderTodayMeals(data.todayMeals ?? [])}
|
||||||
|
|||||||
+184
-4
@@ -310,7 +310,9 @@ let state = {
|
|||||||
users: [],
|
users: [],
|
||||||
filters: { status: '', priority: '', assigned_to: '' },
|
filters: { status: '', priority: '', assigned_to: '' },
|
||||||
groupMode: 'category', // 'category' | 'due'
|
groupMode: 'category', // 'category' | 'due'
|
||||||
|
viewMode: 'list', // 'list' | 'kanban'
|
||||||
expandedTasks: new Set(),
|
expandedTasks: new Set(),
|
||||||
|
dragTaskId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -441,11 +443,159 @@ async function handleAddSubtask(parentId, container) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Kanban-Ansicht
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
const KANBAN_COLS = [
|
||||||
|
{ status: 'open', label: 'Offen', colorVar: '--color-text-secondary' },
|
||||||
|
{ status: 'in_progress', label: 'In Bearbeitung', colorVar: '--color-warning' },
|
||||||
|
{ status: 'done', label: 'Erledigt', colorVar: '--color-success' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderKanbanCard(task) {
|
||||||
|
const due = formatDueDate(task.due_date);
|
||||||
|
return `
|
||||||
|
<div class="kanban-card ${task.status === 'done' ? 'kanban-card--done' : ''}"
|
||||||
|
data-task-id="${task.id}" draggable="true">
|
||||||
|
<div class="kanban-card__title">${task.title}</div>
|
||||||
|
<div class="kanban-card__meta">
|
||||||
|
${renderPriorityBadge(task.priority)}
|
||||||
|
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" style="width:10px;height:10px"></i> ${due.label}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${task.assigned_color ? `
|
||||||
|
<div class="kanban-card__footer">
|
||||||
|
<div class="task-avatar" style="background-color:${task.assigned_color};width:22px;height:22px;font-size:9px"
|
||||||
|
title="${task.assigned_name ?? ''}">
|
||||||
|
${initials(task.assigned_name ?? '')}
|
||||||
|
</div>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKanban(container) {
|
||||||
|
const listEl = container.querySelector('#task-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
for (const col of KANBAN_COLS) grouped[col.status] = [];
|
||||||
|
for (const t of state.tasks) {
|
||||||
|
if (grouped[t.status]) grouped[t.status].push(t);
|
||||||
|
else grouped['open'].push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="kanban-board">
|
||||||
|
${KANBAN_COLS.map((col) => `
|
||||||
|
<div class="kanban-col" data-status="${col.status}">
|
||||||
|
<div class="kanban-col__header">
|
||||||
|
<span class="kanban-col__title" style="color:${col.colorVar.startsWith('--') ? `var(${col.colorVar})` : col.colorVar}">
|
||||||
|
${col.label}
|
||||||
|
</span>
|
||||||
|
<span class="kanban-col__count">${grouped[col.status].length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kanban-col__body" data-drop-zone="${col.status}">
|
||||||
|
${grouped[col.status].map((t) => renderKanbanCard(t)).join('')}
|
||||||
|
<div class="kanban-drop-placeholder" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
wireKanbanDrag(container);
|
||||||
|
updateOverdueBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireKanbanDrag(container) {
|
||||||
|
const board = container.querySelector('.kanban-board');
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
board.addEventListener('dragstart', (e) => {
|
||||||
|
const card = e.target.closest('.kanban-card[data-task-id]');
|
||||||
|
if (!card) return;
|
||||||
|
state.dragTaskId = card.dataset.taskId;
|
||||||
|
card.classList.add('kanban-card--dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
|
||||||
|
board.addEventListener('dragend', (e) => {
|
||||||
|
const card = e.target.closest('.kanban-card[data-task-id]');
|
||||||
|
if (card) card.classList.remove('kanban-card--dragging');
|
||||||
|
board.querySelectorAll('.kanban-drop-placeholder').forEach((el) => el.hidden = true);
|
||||||
|
board.querySelectorAll('.kanban-col__body--over').forEach((el) =>
|
||||||
|
el.classList.remove('kanban-col__body--over')
|
||||||
|
);
|
||||||
|
state.dragTaskId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
board.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const zone = e.target.closest('[data-drop-zone]');
|
||||||
|
if (!zone) return;
|
||||||
|
board.querySelectorAll('.kanban-col__body--over').forEach((el) =>
|
||||||
|
el.classList.remove('kanban-col__body--over')
|
||||||
|
);
|
||||||
|
zone.classList.add('kanban-col__body--over');
|
||||||
|
});
|
||||||
|
|
||||||
|
board.addEventListener('dragleave', (e) => {
|
||||||
|
const zone = e.target.closest('[data-drop-zone]');
|
||||||
|
if (zone && !zone.contains(e.relatedTarget)) {
|
||||||
|
zone.classList.remove('kanban-col__body--over');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
board.addEventListener('drop', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const zone = e.target.closest('[data-drop-zone]');
|
||||||
|
if (!zone || !state.dragTaskId) return;
|
||||||
|
zone.classList.remove('kanban-col__body--over');
|
||||||
|
|
||||||
|
const newStatus = zone.dataset.dropZone;
|
||||||
|
const taskId = state.dragTaskId;
|
||||||
|
const task = state.tasks.find((t) => String(t.id) === String(taskId));
|
||||||
|
if (!task || task.status === newStatus) return;
|
||||||
|
|
||||||
|
// Optimistisches Update
|
||||||
|
task.status = newStatus;
|
||||||
|
renderKanban(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.patch(`/tasks/${taskId}/status`, { status: newStatus });
|
||||||
|
await loadTasks(container); // sync
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos.showToast(err.message, 'danger');
|
||||||
|
await loadTasks(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Klick auf Kanban-Card öffnet Edit-Modal
|
||||||
|
board.addEventListener('click', async (e) => {
|
||||||
|
if (e.target.closest('[draggable]')) {
|
||||||
|
const card = e.target.closest('.kanban-card[data-task-id]');
|
||||||
|
if (!card) return;
|
||||||
|
try {
|
||||||
|
const task = await loadTaskForEdit(card.dataset.taskId);
|
||||||
|
openModal(renderModal({ task, users: state.users }));
|
||||||
|
wireModalEvents(container);
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos.showToast('Aufgabe konnte nicht geladen werden.', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Partielle DOM-Updates
|
// Partielle DOM-Updates
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function renderTaskList(container) {
|
function renderTaskList(container) {
|
||||||
|
if (state.viewMode === 'kanban') {
|
||||||
|
renderKanban(container);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const listEl = container.querySelector('#task-list');
|
const listEl = container.querySelector('#task-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
listEl.innerHTML = renderTaskGroups(state.tasks, state.groupMode);
|
listEl.innerHTML = renderTaskGroups(state.tasks, state.groupMode);
|
||||||
@@ -532,11 +682,30 @@ function wireFilterChips(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireViewToggle(container) {
|
||||||
|
const toggle = container.querySelector('#view-toggle');
|
||||||
|
if (!toggle) return;
|
||||||
|
toggle.querySelectorAll('[data-view]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
state.viewMode = btn.dataset.view;
|
||||||
|
toggle.querySelectorAll('[data-view]').forEach((b) =>
|
||||||
|
b.classList.toggle('group-toggle__btn--active', b.dataset.view === state.viewMode)
|
||||||
|
);
|
||||||
|
// Gruppierungs-Toggle nur in Listenansicht sinnvoll
|
||||||
|
const groupToggle = container.querySelector('#group-mode-toggle');
|
||||||
|
if (groupToggle) groupToggle.style.display = state.viewMode === 'list' ? '' : 'none';
|
||||||
|
renderTaskList(container);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function wireGroupToggle(container) {
|
function wireGroupToggle(container) {
|
||||||
container.querySelectorAll('.group-toggle__btn').forEach((btn) => {
|
const toggle = container.querySelector('#group-mode-toggle');
|
||||||
btn.addEventListener('click', async () => {
|
if (!toggle) return;
|
||||||
|
toggle.querySelectorAll('.group-toggle__btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
state.groupMode = btn.dataset.mode;
|
state.groupMode = btn.dataset.mode;
|
||||||
container.querySelectorAll('.group-toggle__btn').forEach((b) =>
|
toggle.querySelectorAll('.group-toggle__btn').forEach((b) =>
|
||||||
b.classList.toggle('group-toggle__btn--active', b.dataset.mode === state.groupMode)
|
b.classList.toggle('group-toggle__btn--active', b.dataset.mode === state.groupMode)
|
||||||
);
|
);
|
||||||
renderTaskList(container);
|
renderTaskList(container);
|
||||||
@@ -625,7 +794,17 @@ export async function render(container, { user }) {
|
|||||||
<div class="tasks-toolbar">
|
<div class="tasks-toolbar">
|
||||||
<h1 class="tasks-toolbar__title">Aufgaben</h1>
|
<h1 class="tasks-toolbar__title">Aufgaben</h1>
|
||||||
<div class="tasks-toolbar__actions">
|
<div class="tasks-toolbar__actions">
|
||||||
<div class="group-toggle">
|
<div class="group-toggle" id="view-toggle">
|
||||||
|
<button class="group-toggle__btn group-toggle__btn--active" data-view="list"
|
||||||
|
title="Listenansicht" aria-label="Listenansicht">
|
||||||
|
<i data-lucide="list" style="width:14px;height:14px;pointer-events:none"></i>
|
||||||
|
</button>
|
||||||
|
<button class="group-toggle__btn" data-view="kanban"
|
||||||
|
title="Kanban-Ansicht" aria-label="Kanban-Ansicht">
|
||||||
|
<i data-lucide="columns" style="width:14px;height:14px;pointer-events:none"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="group-toggle" id="group-mode-toggle">
|
||||||
<button class="group-toggle__btn group-toggle__btn--active" data-mode="category">Kategorie</button>
|
<button class="group-toggle__btn group-toggle__btn--active" data-mode="category">Kategorie</button>
|
||||||
<button class="group-toggle__btn" data-mode="due">Fälligkeit</button>
|
<button class="group-toggle__btn" data-mode="due">Fälligkeit</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,6 +845,7 @@ export async function render(container, { user }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UI verdrahten
|
// UI verdrahten
|
||||||
|
wireViewToggle(container);
|
||||||
wireGroupToggle(container);
|
wireGroupToggle(container);
|
||||||
wireNewTaskBtn(container);
|
wireNewTaskBtn(container);
|
||||||
wireTaskList(container);
|
wireTaskList(container);
|
||||||
|
|||||||
@@ -329,6 +329,95 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Wetter-Widget
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.weather-widget {
|
||||||
|
background: linear-gradient(135deg, #1a73e8 0%, #0f4fa8 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__temp {
|
||||||
|
font-size: var(--text-4xl, 2.25rem);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__desc {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-transform: capitalize;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__city {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
opacity: 0.85;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-widget__icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.2);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast__day {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
opacity: 0.85;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast__icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast__temps {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast__high {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-forecast__low {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Skeleton-Zustände (pro Widget)
|
* Skeleton-Zustände (pro Widget)
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -495,6 +495,128 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Kanban-Board
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.kanban-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: start;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.kanban-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col {
|
||||||
|
background-color: var(--color-surface-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1.5px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col__count {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col__body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 80px;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-col__body--over {
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--space-3);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
transition: box-shadow var(--transition-fast), opacity var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card--dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: rotate(1.5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card--done {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card--done .kanban-card__title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-drop-placeholder {
|
||||||
|
height: 60px;
|
||||||
|
border: 2px dashed var(--color-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------
|
/* --------------------------------------------------------
|
||||||
* Leer-Zustand
|
* Leer-Zustand
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
+9
-1
@@ -4,7 +4,7 @@
|
|||||||
* Abhängigkeiten: keine
|
* Abhängigkeiten: keine
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = 'oikos-v1';
|
const CACHE_NAME = 'oikos-v2';
|
||||||
|
|
||||||
// App-Shell-Ressourcen, die offline verfügbar sein sollen
|
// App-Shell-Ressourcen, die offline verfügbar sein sollen
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
@@ -16,6 +16,14 @@ const APP_SHELL = [
|
|||||||
'/styles/reset.css',
|
'/styles/reset.css',
|
||||||
'/styles/layout.css',
|
'/styles/layout.css',
|
||||||
'/styles/login.css',
|
'/styles/login.css',
|
||||||
|
'/styles/dashboard.css',
|
||||||
|
'/styles/tasks.css',
|
||||||
|
'/styles/shopping.css',
|
||||||
|
'/styles/meals.css',
|
||||||
|
'/styles/calendar.css',
|
||||||
|
'/styles/notes.css',
|
||||||
|
'/styles/contacts.css',
|
||||||
|
'/styles/budget.css',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
|
const { nextOccurrence } = require('../services/recurrence');
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Konstanten
|
// Konstanten
|
||||||
@@ -253,6 +254,25 @@ router.patch('/:id/status', (req, res) => {
|
|||||||
if (result.changes === 0)
|
if (result.changes === 0)
|
||||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
|
return res.status(404).json({ error: 'Aufgabe nicht gefunden.', code: 404 });
|
||||||
|
|
||||||
|
// Wiederkehrende Aufgabe: nächste Instanz erstellen wenn erledigt
|
||||||
|
if (status === 'done') {
|
||||||
|
const task = db.get().prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id);
|
||||||
|
if (task?.is_recurring && task.recurrence_rule && !task.parent_task_id) {
|
||||||
|
const nextDate = nextOccurrence(task.due_date, task.recurrence_rule);
|
||||||
|
if (nextDate) {
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO tasks (title, description, category, priority, status,
|
||||||
|
due_date, due_time, assigned_to, created_by, is_recurring, recurrence_rule)
|
||||||
|
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, 1, ?)
|
||||||
|
`).run(
|
||||||
|
task.title, task.description, task.category, task.priority,
|
||||||
|
nextDate, task.due_time, task.assigned_to, task.created_by,
|
||||||
|
task.recurrence_rule
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ data: { id: Number(req.params.id), status } });
|
res.json({ data: { id: Number(req.params.id), status } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Tasks] PATCH /:id/status Fehler:', err);
|
console.error('[Tasks] PATCH /:id/status Fehler:', err);
|
||||||
|
|||||||
@@ -4,10 +4,92 @@
|
|||||||
* Abhängigkeiten: express, node-fetch, dotenv
|
* Abhängigkeiten: express, node-fetch, dotenv
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Platzhalter — wird in Phase 4 implementiert
|
// Cache: Daten für 30 Minuten halten
|
||||||
router.get('/', (req, res) => res.json({ data: null }));
|
let cache = { data: null, ts: 0 };
|
||||||
|
const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// GET /api/v1/weather
|
||||||
|
// Gibt aktuelles Wetter + 3-Tage-Vorschau zurück.
|
||||||
|
// Erfordert OPENWEATHER_API_KEY + OPENWEATHER_CITY in .env
|
||||||
|
// Response: { data: { current, forecast } } | { data: null }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.OPENWEATHER_API_KEY;
|
||||||
|
const city = process.env.OPENWEATHER_CITY || 'Berlin';
|
||||||
|
const units = process.env.OPENWEATHER_UNITS || 'metric';
|
||||||
|
const lang = process.env.OPENWEATHER_LANG || 'de';
|
||||||
|
|
||||||
|
// Kein API-Key → leere Antwort (Widget wird ausgeblendet)
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.json({ data: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache prüfen
|
||||||
|
if (cache.data && Date.now() - cache.ts < CACHE_TTL_MS) {
|
||||||
|
return res.json({ data: cache.data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamischer Import für node-fetch (ESM)
|
||||||
|
const { default: fetch } = await import('node-fetch');
|
||||||
|
|
||||||
|
// Aktuelles Wetter
|
||||||
|
const currentUrl = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}`;
|
||||||
|
const currentRes = await fetch(currentUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!currentRes.ok) {
|
||||||
|
console.warn(`[Weather] API Fehler: ${currentRes.status}`);
|
||||||
|
return res.json({ data: null });
|
||||||
|
}
|
||||||
|
const currentJson = await currentRes.json();
|
||||||
|
|
||||||
|
// 5-Tage-Forecast (3h-Intervalle → wir nehmen Mittags-Werte für Tagesvorschau)
|
||||||
|
const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}&cnt=24`;
|
||||||
|
const forecastRes = await fetch(forecastUrl, { signal: AbortSignal.timeout(8000) });
|
||||||
|
let forecastDays = [];
|
||||||
|
if (forecastRes.ok) {
|
||||||
|
const forecastJson = await forecastRes.json();
|
||||||
|
// Ein Eintrag pro Tag: nächstgelegener Mittags-Wert (12:00 Uhr)
|
||||||
|
const seen = new Set();
|
||||||
|
for (const item of forecastJson.list ?? []) {
|
||||||
|
const dateStr = item.dt_txt.slice(0, 10); // YYYY-MM-DD
|
||||||
|
if (seen.has(dateStr)) continue;
|
||||||
|
seen.add(dateStr);
|
||||||
|
forecastDays.push({
|
||||||
|
date: dateStr,
|
||||||
|
temp_min: Math.round(item.main.temp_min),
|
||||||
|
temp_max: Math.round(item.main.temp_max),
|
||||||
|
icon: item.weather[0]?.icon,
|
||||||
|
desc: item.weather[0]?.description,
|
||||||
|
});
|
||||||
|
if (forecastDays.length >= 3) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
city: currentJson.name,
|
||||||
|
current: {
|
||||||
|
temp: Math.round(currentJson.main.temp),
|
||||||
|
feels_like: Math.round(currentJson.main.feels_like),
|
||||||
|
humidity: currentJson.main.humidity,
|
||||||
|
icon: currentJson.weather[0]?.icon,
|
||||||
|
desc: currentJson.weather[0]?.description,
|
||||||
|
wind_speed: Math.round((currentJson.wind?.speed ?? 0) * 3.6), // m/s → km/h
|
||||||
|
},
|
||||||
|
forecast: forecastDays,
|
||||||
|
};
|
||||||
|
|
||||||
|
cache = { data, ts: Date.now() };
|
||||||
|
res.json({ data });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Weather] Fehler:', err.message);
|
||||||
|
res.json({ data: null }); // Fallback: Widget ausblenden, kein Error-Screen
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,12 +1,99 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Wiederholungsregeln (Recurrence)
|
* Modul: Wiederholungsregeln (Recurrence)
|
||||||
* Zweck: RRULE-Parser und -Generator für wiederkehrende Aufgaben und Termine
|
* Zweck: RRULE-Subset-Parser (FREQ=DAILY/WEEKLY/MONTHLY, BYDAY, INTERVAL, UNTIL)
|
||||||
* Abhängigkeiten: keine externen (eigene Implementierung für FREQ=DAILY/WEEKLY/MONTHLY)
|
* + Berechnung des nächsten Fälligkeitsdatums für wiederkehrende Aufgaben
|
||||||
|
* Abhängigkeiten: keine
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Platzhalter — wird in Phase 4 implementiert
|
'use strict';
|
||||||
|
|
||||||
module.exports = {
|
const DAY_MAP = { MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 0 };
|
||||||
nextOccurrence: () => null,
|
|
||||||
expandRule: () => [],
|
/**
|
||||||
};
|
* Parsed einen RRULE-String in ein Objekt.
|
||||||
|
* Beispiel: "FREQ=WEEKLY;BYDAY=MO,TH;INTERVAL=1"
|
||||||
|
* @param {string} rule
|
||||||
|
* @returns {{ freq, interval, byday, until }|null}
|
||||||
|
*/
|
||||||
|
function parseRRule(rule) {
|
||||||
|
if (!rule) return null;
|
||||||
|
const parts = {};
|
||||||
|
for (const segment of rule.split(';')) {
|
||||||
|
const eq = segment.indexOf('=');
|
||||||
|
if (eq === -1) continue;
|
||||||
|
parts[segment.slice(0, eq).toUpperCase()] = segment.slice(eq + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const freq = parts.FREQ ?? null;
|
||||||
|
const interval = parseInt(parts.INTERVAL ?? '1', 10) || 1;
|
||||||
|
const byday = (parts.BYDAY ?? '').split(',')
|
||||||
|
.map((d) => DAY_MAP[d.trim().toUpperCase()])
|
||||||
|
.filter((d) => d !== undefined);
|
||||||
|
const until = parts.UNTIL ? parseUntilDate(parts.UNTIL) : null;
|
||||||
|
|
||||||
|
if (!['DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) return null;
|
||||||
|
|
||||||
|
return { freq, interval, byday, until };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUntilDate(str) {
|
||||||
|
// Akzeptiert YYYYMMDD oder YYYYMMDDTHHmmssZ
|
||||||
|
const clean = str.replace(/[TZ]/g, '');
|
||||||
|
const y = parseInt(clean.slice(0, 4), 10);
|
||||||
|
const m = parseInt(clean.slice(4, 6), 10) - 1;
|
||||||
|
const d = parseInt(clean.slice(6, 8), 10);
|
||||||
|
return new Date(Date.UTC(y, m, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet das nächste Fälligkeitsdatum nach dem gegebenen Basisdatum.
|
||||||
|
* @param {string} baseDateStr - ISO-Datums-String (YYYY-MM-DD)
|
||||||
|
* @param {string} rrule - RRULE-String
|
||||||
|
* @returns {string|null} - Nächstes Datum als YYYY-MM-DD oder null (Ende der Serie)
|
||||||
|
*/
|
||||||
|
function nextOccurrence(baseDateStr, rrule) {
|
||||||
|
const parsed = parseRRule(rrule);
|
||||||
|
if (!parsed || !baseDateStr) return null;
|
||||||
|
|
||||||
|
const base = new Date(baseDateStr + 'T00:00:00Z');
|
||||||
|
if (isNaN(base.getTime())) return null;
|
||||||
|
|
||||||
|
const { freq, interval, byday, until } = parsed;
|
||||||
|
const next = new Date(base);
|
||||||
|
|
||||||
|
if (freq === 'DAILY') {
|
||||||
|
next.setUTCDate(next.getUTCDate() + interval);
|
||||||
|
|
||||||
|
} else if (freq === 'WEEKLY') {
|
||||||
|
if (byday.length === 0) {
|
||||||
|
// Kein BYDAY → selber Wochentag, nächste Woche
|
||||||
|
next.setUTCDate(next.getUTCDate() + 7 * interval);
|
||||||
|
} else {
|
||||||
|
// Finde den nächsten passenden Wochentag (nach heute)
|
||||||
|
const currentDay = base.getUTCDay();
|
||||||
|
const sorted = [...byday].sort((a, b) => {
|
||||||
|
const da = (a - currentDay + 7) % 7 || 7;
|
||||||
|
const db = (b - currentDay + 7) % 7 || 7;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
// Tage bis zum nächsten Vorkommen (mind. 1, damit nicht derselbe Tag)
|
||||||
|
let daysUntil = (sorted[0] - currentDay + 7) % 7;
|
||||||
|
if (daysUntil === 0) daysUntil = 7 * interval;
|
||||||
|
next.setUTCDate(next.getUTCDate() + daysUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (freq === 'MONTHLY') {
|
||||||
|
const targetDay = base.getUTCDate();
|
||||||
|
next.setUTCMonth(next.getUTCMonth() + interval);
|
||||||
|
// Monatsüberlauf korrigieren (z.B. 31. März + 1 Monat → 30. April)
|
||||||
|
const lastDay = new Date(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 0)).getUTCDate();
|
||||||
|
next.setUTCDate(Math.min(targetDay, lastDay));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNTIL-Grenze prüfen
|
||||||
|
if (until && next > until) return null;
|
||||||
|
|
||||||
|
return next.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseRRule, nextOccurrence };
|
||||||
|
|||||||
Reference in New Issue
Block a user