refactor(todo): extract shared useTaskForm composable

Extract 16 duplicated $state declarations, form initialization logic,
and save/buildUpdateInput from TaskItem and TaskEditModal into a shared
useTaskForm.svelte.ts composable. Uses getter/setter pattern for
Svelte 5 bind:value compatibility.

Eliminates ~200 lines of duplicated form state management.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 13:34:52 +02:00
parent 4f8209fd5a
commit c3bee2607b
3 changed files with 373 additions and 266 deletions

View file

@ -1,13 +1,5 @@
<script lang="ts">
import type {
Task,
Subtask,
TaskPriority,
TaskStatus,
EffectiveDuration,
UpdateTaskInput,
} from '@todo/shared';
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
import type { Task, Subtask, UpdateTaskInput } from '@todo/shared';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
@ -15,7 +7,7 @@
const projectsCtx: { readonly value: Project[] } = getContext('projects');
import { contactsStore } from '$lib/stores/contacts.svelte';
import { format } from 'date-fns';
import { useTaskForm } from '$lib/composables/useTaskForm.svelte';
import SubtaskList from './SubtaskList.svelte';
import {
PrioritySelector,
@ -39,61 +31,15 @@
let { task, open, onClose, onSave, onDelete }: Props = $props();
// Form state - initialized from task
let title = $state('');
let description = $state('');
let dueDate = $state('');
let dueTime = $state('');
let startDate = $state('');
let priority = $state<TaskPriority>('medium');
let status = $state<TaskStatus>('pending');
let projectId = $state<string | null>(null);
let selectedLabelIds = $state<string[]>([]);
let subtasks = $state<Subtask[]>([]);
let recurrenceRule = $state('');
let notes = $state('');
let storyPoints = $state<number | null>(null);
let effectiveDuration = $state<EffectiveDuration | null>(null);
let funRating = $state<number | null>(null);
const form = useTaskForm();
// Link picker state
let showLinkPicker = $state(false);
// Contact associations
let assignee = $state<ContactOrManual[]>([]);
let involvedContacts = $state<ContactOrManual[]>([]);
let contactsAvailable = $state<boolean | null>(null);
// UI state
let isLoading = $state(false);
let showDeleteConfirm = $state(false);
// Initialize form when task changes or modal opens
$effect(() => {
if (open && task) {
title = task.title || '';
description = task.description || '';
dueDate = task.dueDate ? format(new Date(task.dueDate), 'yyyy-MM-dd') : '';
dueTime = task.dueTime || '';
startDate = task.startDate ? format(new Date(task.startDate), 'yyyy-MM-dd') : '';
priority = task.priority || 'medium';
status = task.status || 'pending';
projectId = task.projectId || null;
selectedLabelIds = task.labels?.map((l) => l.id) || [];
subtasks = task.subtasks ? [...task.subtasks] : [];
recurrenceRule = task.recurrenceRule || '';
notes = task.metadata?.notes || '';
// Metadata fields
storyPoints = task.metadata?.storyPoints ?? null;
effectiveDuration = task.metadata?.effectiveDuration ?? null;
funRating = task.metadata?.funRating ?? null;
// Contact associations
assignee = task.metadata?.assignee ? [task.metadata.assignee] : [];
involvedContacts = task.metadata?.involvedContacts || [];
showDeleteConfirm = false;
// Check contacts availability
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
form.initFromTask(task);
}
});
@ -112,65 +58,27 @@
}
}
// Extract ContactReference from ContactOrManual (filter out manual entries for now)
function toContactReference(contact: ContactOrManual): ContactReference | null {
if ('isManual' in contact && contact.isManual) {
return null; // Manual entries not stored as contacts
}
return contact as ContactReference;
}
async function handleSave() {
if (!title.trim()) return;
if (!form.title.trim()) return;
isLoading = true;
form.isLoading = true;
try {
// Convert assignee array to single ContactReference
const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null;
// Convert involved contacts to array of ContactReferences
const involvedRefs = involvedContacts
.map(toContactReference)
.filter((c): c is ContactReference => c !== null);
const data: UpdateTaskInput = {
title: title.trim(),
description: description.trim() || null,
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
dueTime: dueTime || null,
startDate: startDate ? new Date(startDate).toISOString() : null,
priority,
status,
projectId: projectId || null,
subtasks: subtasks.length > 0 ? subtasks : null,
recurrenceRule: recurrenceRule || null,
metadata: {
...task.metadata,
notes: notes.trim() || undefined,
storyPoints: storyPoints ?? undefined,
effectiveDuration: effectiveDuration ?? undefined,
funRating: funRating ?? undefined,
assignee: assigneeRef ?? undefined,
involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined,
},
labelIds: selectedLabelIds,
};
onSave(data);
onSave(form.buildUpdateInput(task));
} finally {
isLoading = false;
form.isLoading = false;
}
}
function handleDelete() {
if (showDeleteConfirm) {
if (form.showDeleteConfirm) {
onDelete(task.id);
} else {
showDeleteConfirm = true;
form.showDeleteConfirm = true;
}
}
function handleSubtasksChange(newSubtasks: Subtask[]) {
subtasks = newSubtasks;
form.subtasks = newSubtasks;
}
</script>
@ -202,7 +110,7 @@
id="task-title"
type="text"
class="form-input"
bind:value={title}
bind:value={form.title}
placeholder="Aufgabentitel..."
/>
</div>
@ -213,7 +121,7 @@
<textarea
id="task-description"
class="form-textarea"
bind:value={description}
bind:value={form.description}
placeholder="Beschreibung hinzufügen..."
rows="3"
></textarea>
@ -223,15 +131,15 @@
<div class="form-section">
<label class="form-label">Zuständig</label>
<ContactSelector
selectedContacts={assignee}
onContactsChange={(contacts) => (assignee = contacts)}
selectedContacts={form.assignee}
onContactsChange={(contacts) => (form.assignee = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
singleSelect={true}
allowManualEntry={false}
placeholder="Person zuweisen..."
addLabel="Zuweisen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
@ -239,14 +147,14 @@
<div class="form-section">
<label class="form-label">Beteiligte</label>
<ContactSelector
selectedContacts={involvedContacts}
onContactsChange={(contacts) => (involvedContacts = contacts)}
selectedContacts={form.involvedContacts}
onContactsChange={(contacts) => (form.involvedContacts = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
allowManualEntry={false}
placeholder="Personen hinzufügen..."
addLabel="Person hinzufügen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
@ -256,15 +164,20 @@
<div class="form-row">
<div class="form-field">
<label class="form-sublabel" for="due-date">Fälligkeitsdatum</label>
<input id="due-date" type="date" class="form-input-sm" bind:value={dueDate} />
<input id="due-date" type="date" class="form-input-sm" bind:value={form.dueDate} />
</div>
<div class="form-field">
<label class="form-sublabel" for="due-time">Uhrzeit</label>
<input id="due-time" type="time" class="form-input-sm" bind:value={dueTime} />
<input id="due-time" type="time" class="form-input-sm" bind:value={form.dueTime} />
</div>
<div class="form-field">
<label class="form-sublabel" for="start-date">Startdatum</label>
<input id="start-date" type="date" class="form-input-sm" bind:value={startDate} />
<input
id="start-date"
type="date"
class="form-input-sm"
bind:value={form.startDate}
/>
</div>
</div>
</div>
@ -272,13 +185,13 @@
<!-- Priorität -->
<div class="form-section">
<label class="form-label">Priorität</label>
<PrioritySelector value={priority} onChange={(p) => (priority = p)} />
<PrioritySelector value={form.priority} onChange={(p) => (form.priority = p)} />
</div>
<!-- Status -->
<div class="form-section">
<label class="form-label" for="task-status">Status</label>
<select id="task-status" class="form-select" bind:value={status}>
<select id="task-status" class="form-select" bind:value={form.status}>
{#each STATUS_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
@ -288,7 +201,7 @@
<!-- Projekt -->
<div class="form-section">
<label class="form-label" for="task-project">Projekt</label>
<select id="task-project" class="form-select" bind:value={projectId}>
<select id="task-project" class="form-select" bind:value={form.projectId}>
<option value={null}>Kein Projekt</option>
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>
@ -302,15 +215,15 @@
<div class="form-section">
<label class="form-label">Tags</label>
<TagSelector
selectedIds={selectedLabelIds}
onChange={(ids) => (selectedLabelIds = ids)}
selectedIds={form.selectedLabelIds}
onChange={(ids) => (form.selectedLabelIds = ids)}
/>
</div>
<!-- Subtasks -->
<div class="form-section">
<label class="form-label">Subtasks</label>
<SubtaskList {subtasks} onChange={handleSubtasksChange} />
<SubtaskList subtasks={form.subtasks} onChange={handleSubtasksChange} />
</div>
<!-- Verknüpfungen -->
@ -330,7 +243,7 @@
<ManaLinkPicker
sourceRef={{ app: 'todo', collection: 'tasks', id: task.id }}
sourceTitle={title || task.title}
sourceTitle={form.title || task.title}
open={showLinkPicker}
onClose={() => (showLinkPicker = false)}
onSearch={searchCrossApp}
@ -339,7 +252,7 @@
<!-- Wiederholung -->
<div class="form-section">
<label class="form-label" for="task-recurrence">Wiederholung</label>
<select id="task-recurrence" class="form-select" bind:value={recurrenceRule}>
<select id="task-recurrence" class="form-select" bind:value={form.recurrenceRule}>
{#each RECURRENCE_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
@ -352,7 +265,7 @@
<textarea
id="task-notes"
class="form-textarea"
bind:value={notes}
bind:value={form.notes}
placeholder="Zusätzliche Notizen..."
rows="3"
></textarea>
@ -361,41 +274,55 @@
<!-- Storypoints -->
<div class="form-section">
<label class="form-label">Storypoints</label>
<StorypointsSelector value={storyPoints} onChange={(v) => (storyPoints = v)} />
<StorypointsSelector value={form.storyPoints} onChange={(v) => (form.storyPoints = v)} />
</div>
<!-- Effektive Dauer -->
<div class="form-section">
<label class="form-label">Effektive Dauer</label>
<DurationPicker value={effectiveDuration} onChange={(v) => (effectiveDuration = v)} />
<DurationPicker
value={form.effectiveDuration}
onChange={(v) => (form.effectiveDuration = v)}
/>
</div>
<!-- Spaß-Faktor -->
<div class="form-section">
<label class="form-label">
Spaß-Faktor{#if funRating !== null}: <span class="fun-rating-value">{funRating}</span
Spaß-Faktor{#if form.funRating !== null}: <span class="fun-rating-value"
>{form.funRating}</span
>{/if}
</label>
<FunRatingPicker value={funRating} onChange={(v) => (funRating = v)} />
<FunRatingPicker value={form.funRating} onChange={(v) => (form.funRating = v)} />
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<button type="button" class="btn btn-danger" onclick={handleDelete} disabled={isLoading}>
{showDeleteConfirm ? 'Wirklich löschen?' : 'Löschen'}
<button
type="button"
class="btn btn-danger"
onclick={handleDelete}
disabled={form.isLoading}
>
{form.showDeleteConfirm ? 'Wirklich löschen?' : 'Löschen'}
</button>
<div class="footer-right">
<button type="button" class="btn btn-secondary" onclick={onClose} disabled={isLoading}>
<button
type="button"
class="btn btn-secondary"
onclick={onClose}
disabled={form.isLoading}
>
Abbrechen
</button>
<button
type="button"
class="btn btn-primary"
onclick={handleSave}
disabled={isLoading || !title.trim()}
disabled={form.isLoading || !form.title.trim()}
>
{#if isLoading}
{#if form.isLoading}
<div class="spinner"></div>
{:else}
Speichern

View file

@ -1,18 +1,11 @@
<script lang="ts">
import type {
Task,
Subtask,
TaskPriority,
TaskStatus,
EffectiveDuration,
UpdateTaskInput,
} from '@todo/shared';
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
import type { Task, Subtask, UpdateTaskInput } from '@todo/shared';
import { STATUS_OPTIONS, RECURRENCE_OPTIONS } from '@todo/shared';
import { format, isToday, isPast } from 'date-fns';
import { de } from 'date-fns/locale';
import { formatDueDate } from '$lib/utils/date-display';
import { getSubtaskProgress } from '$lib/utils/task-helpers';
import { useTaskForm } from '$lib/composables/useTaskForm.svelte';
import { getContext } from 'svelte';
import type { Project } from '@todo/shared';
import { getActiveProjects, getProjectColor } from '$lib/data/task-queries';
@ -58,27 +51,9 @@
// Toggle for showing created date on completed tasks
let showCreatedDate = $state(false);
// Form state for expanded mode
let title = $state('');
let description = $state('');
let dueDate = $state('');
let dueTime = $state('');
let startDate = $state('');
let priority = $state<TaskPriority>('medium');
let status = $state<TaskStatus>('pending');
let projectId = $state<string | null>(null);
let selectedLabelIds = $state<string[]>([]);
let subtasks = $state<Subtask[]>([]);
let recurrenceRule = $state('');
let notes = $state('');
let storyPoints = $state<number | null>(null);
let effectiveDuration = $state<EffectiveDuration | null>(null);
let funRating = $state<number | null>(null);
let assignee = $state<ContactOrManual[]>([]);
let involvedContacts = $state<ContactOrManual[]>([]);
let contactsAvailable = $state<boolean | null>(null);
let isLoading = $state(false);
let showDeleteConfirm = $state(false);
// Shared form state
const form = useTaskForm();
let titleInputRef = $state<HTMLInputElement | null>(null);
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null;
let isInitializing = $state(false);
@ -95,28 +70,7 @@
$effect(() => {
if (isExpanded && task) {
isInitializing = true;
title = task.title || '';
description = task.description || '';
dueDate = task.dueDate ? format(new Date(task.dueDate), 'yyyy-MM-dd') : '';
dueTime = task.dueTime || '';
startDate = task.startDate ? format(new Date(task.startDate), 'yyyy-MM-dd') : '';
priority = task.priority || 'medium';
status = task.status || 'pending';
projectId = task.projectId || null;
selectedLabelIds = task.labels?.map((l) => l.id) || [];
subtasks = task.subtasks ? [...task.subtasks] : [];
recurrenceRule = task.recurrenceRule || '';
notes = task.metadata?.notes || '';
storyPoints = task.metadata?.storyPoints ?? null;
effectiveDuration = task.metadata?.effectiveDuration ?? null;
funRating = task.metadata?.funRating ?? null;
assignee = task.metadata?.assignee ? [task.metadata.assignee] : [];
involvedContacts = task.metadata?.involvedContacts || [];
showDeleteConfirm = false;
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
form.initFromTask(task);
// Allow a tick for all state to settle before enabling auto-save
setTimeout(() => {
@ -138,23 +92,23 @@
$effect(() => {
// Read all reactive form fields to create dependencies
void [
title,
description,
dueDate,
dueTime,
startDate,
priority,
status,
projectId,
selectedLabelIds,
subtasks,
recurrenceRule,
notes,
storyPoints,
effectiveDuration,
funRating,
assignee,
involvedContacts,
form.title,
form.description,
form.dueDate,
form.dueTime,
form.startDate,
form.priority,
form.status,
form.projectId,
form.selectedLabelIds,
form.subtasks,
form.recurrenceRule,
form.notes,
form.storyPoints,
form.effectiveDuration,
form.funRating,
form.assignee,
form.involvedContacts,
];
scheduleAutoSave();
});
@ -236,62 +190,28 @@
}
}
function toContactReference(contact: ContactOrManual): ContactReference | null {
if ('isManual' in contact && contact.isManual) {
return null;
}
return contact as ContactReference;
}
async function handleSave() {
if (!title.trim() || !onSave) return;
if (!form.title.trim() || !onSave) return;
isLoading = true;
form.isLoading = true;
try {
const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null;
const involvedRefs = involvedContacts
.map(toContactReference)
.filter((c): c is ContactReference => c !== null);
const data: UpdateTaskInput = {
title: title.trim(),
description: description.trim() || null,
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
dueTime: dueTime || null,
startDate: startDate ? new Date(startDate).toISOString() : null,
priority,
status,
projectId: projectId || null,
subtasks: subtasks.length > 0 ? subtasks : null,
recurrenceRule: recurrenceRule || null,
metadata: {
...task.metadata,
notes: notes.trim() || undefined,
storyPoints: storyPoints ?? undefined,
effectiveDuration: effectiveDuration ?? undefined,
funRating: funRating ?? undefined,
assignee: assigneeRef ?? undefined,
involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined,
},
labelIds: selectedLabelIds,
};
const data = form.buildUpdateInput(task);
onSave(data);
} finally {
isLoading = false;
form.isLoading = false;
}
}
function handleDeleteClick() {
if (showDeleteConfirm) {
if (form.showDeleteConfirm) {
onDelete();
} else {
showDeleteConfirm = true;
form.showDeleteConfirm = true;
}
}
function handleSubtasksChange(newSubtasks: Subtask[]) {
subtasks = newSubtasks;
form.subtasks = newSubtasks;
}
const priorityColors = PRIORITY_COLORS;
@ -502,7 +422,7 @@
id="task-title-{task.id}"
type="text"
class="form-input"
bind:value={title}
bind:value={form.title}
placeholder="Aufgabentitel..."
/>
</div>
@ -513,7 +433,7 @@
<textarea
id="task-description-{task.id}"
class="form-textarea"
bind:value={description}
bind:value={form.description}
placeholder="Beschreibung hinzufügen..."
rows="2"
></textarea>
@ -525,11 +445,21 @@
<div class="form-row">
<div class="form-field">
<label class="form-sublabel" for="due-date-{task.id}">Fällig</label>
<input id="due-date-{task.id}" type="date" class="form-input-sm" bind:value={dueDate} />
<input
id="due-date-{task.id}"
type="date"
class="form-input-sm"
bind:value={form.dueDate}
/>
</div>
<div class="form-field">
<label class="form-sublabel" for="due-time-{task.id}">Uhrzeit</label>
<input id="due-time-{task.id}" type="time" class="form-input-sm" bind:value={dueTime} />
<input
id="due-time-{task.id}"
type="time"
class="form-input-sm"
bind:value={form.dueTime}
/>
</div>
<div class="form-field">
<label class="form-sublabel" for="start-date-{task.id}">Start</label>
@ -537,7 +467,7 @@
id="start-date-{task.id}"
type="date"
class="form-input-sm"
bind:value={startDate}
bind:value={form.startDate}
/>
</div>
</div>
@ -546,14 +476,14 @@
<!-- Priority -->
<div class="form-section">
<label class="form-label">Priorität</label>
<PrioritySelector value={priority} onChange={(p) => (priority = p)} />
<PrioritySelector value={form.priority} onChange={(p) => (form.priority = p)} />
</div>
<!-- Status & Project row -->
<div class="form-row-2">
<div class="form-section">
<label class="form-label" for="task-status-{task.id}">Status</label>
<select id="task-status-{task.id}" class="form-select" bind:value={status}>
<select id="task-status-{task.id}" class="form-select" bind:value={form.status}>
{#each STATUS_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
@ -561,7 +491,7 @@
</div>
<div class="form-section">
<label class="form-label" for="task-project-{task.id}">Projekt</label>
<select id="task-project-{task.id}" class="form-select" bind:value={projectId}>
<select id="task-project-{task.id}" class="form-select" bind:value={form.projectId}>
<option value={null}>Kein Projekt</option>
{#each getActiveProjects(projectsCtx.value) as project}
<option value={project.id}>{project.name}</option>
@ -573,19 +503,22 @@
<!-- Tags -->
<div class="form-section">
<label class="form-label">Tags</label>
<TagSelector selectedIds={selectedLabelIds} onChange={(ids) => (selectedLabelIds = ids)} />
<TagSelector
selectedIds={form.selectedLabelIds}
onChange={(ids) => (form.selectedLabelIds = ids)}
/>
</div>
<!-- Subtasks -->
<div class="form-section">
<label class="form-label">Subtasks</label>
<SubtaskList {subtasks} onChange={handleSubtasksChange} />
<SubtaskList subtasks={form.subtasks} onChange={handleSubtasksChange} />
</div>
<!-- Recurrence -->
<div class="form-section">
<label class="form-label" for="task-recurrence-{task.id}">Wiederholung</label>
<select id="task-recurrence-{task.id}" class="form-select" bind:value={recurrenceRule}>
<select id="task-recurrence-{task.id}" class="form-select" bind:value={form.recurrenceRule}>
{#each RECURRENCE_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
@ -596,15 +529,15 @@
<div class="form-section">
<label class="form-label">Zuständig</label>
<ContactSelector
selectedContacts={assignee}
onContactsChange={(contacts) => (assignee = contacts)}
selectedContacts={form.assignee}
onContactsChange={(contacts) => (form.assignee = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
singleSelect={true}
allowManualEntry={false}
placeholder="Person zuweisen..."
addLabel="Zuweisen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
@ -612,14 +545,14 @@
<div class="form-section">
<label class="form-label">Beteiligte</label>
<ContactSelector
selectedContacts={involvedContacts}
onContactsChange={(contacts) => (involvedContacts = contacts)}
selectedContacts={form.involvedContacts}
onContactsChange={(contacts) => (form.involvedContacts = contacts)}
onSearch={(q) => contactsStore.searchContacts(q)}
allowManualEntry={false}
placeholder="Personen hinzufügen..."
addLabel="Person hinzufügen"
searchPlaceholder="Name oder E-Mail..."
isAvailable={contactsAvailable ?? false}
isAvailable={form.contactsAvailable ?? false}
/>
</div>
@ -629,7 +562,7 @@
<textarea
id="task-notes-{task.id}"
class="form-textarea"
bind:value={notes}
bind:value={form.notes}
placeholder="Zusätzliche Notizen..."
rows="2"
></textarea>
@ -639,15 +572,18 @@
<div class="form-row-3">
<div class="form-section">
<label class="form-label">Storypoints</label>
<StorypointsSelector value={storyPoints} onChange={(v) => (storyPoints = v)} />
<StorypointsSelector value={form.storyPoints} onChange={(v) => (form.storyPoints = v)} />
</div>
<div class="form-section">
<label class="form-label">Dauer</label>
<DurationPicker value={effectiveDuration} onChange={(v) => (effectiveDuration = v)} />
<DurationPicker
value={form.effectiveDuration}
onChange={(v) => (form.effectiveDuration = v)}
/>
</div>
<div class="form-section">
<label class="form-label">Spaß</label>
<FunRatingPicker value={funRating} onChange={(v) => (funRating = v)} />
<FunRatingPicker value={form.funRating} onChange={(v) => (form.funRating = v)} />
</div>
</div>
@ -657,9 +593,9 @@
type="button"
class="btn btn-danger"
onclick={handleDeleteClick}
disabled={isLoading}
disabled={form.isLoading}
>
{showDeleteConfirm ? 'Wirklich löschen?' : 'Löschen'}
{form.showDeleteConfirm ? 'Wirklich löschen?' : 'Löschen'}
</button>
</div>
</div>

View file

@ -0,0 +1,244 @@
import type {
Task,
Subtask,
TaskPriority,
TaskStatus,
EffectiveDuration,
UpdateTaskInput,
} from '@todo/shared';
import type { ContactReference, ContactOrManual } from '@manacore/shared-types';
import { format } from 'date-fns';
import { contactsStore } from '$lib/stores/contacts.svelte';
/**
* Shared composable for task form state and logic.
* Used by both TaskItem (inline edit) and TaskEditModal.
*/
export function useTaskForm() {
// Form state
let title = $state('');
let description = $state('');
let dueDate = $state('');
let dueTime = $state('');
let startDate = $state('');
let priority = $state<TaskPriority>('medium');
let status = $state<TaskStatus>('pending');
let projectId = $state<string | null>(null);
let selectedLabelIds = $state<string[]>([]);
let subtasks = $state<Subtask[]>([]);
let recurrenceRule = $state('');
let notes = $state('');
let storyPoints = $state<number | null>(null);
let effectiveDuration = $state<EffectiveDuration | null>(null);
let funRating = $state<number | null>(null);
let assignee = $state<ContactOrManual[]>([]);
let involvedContacts = $state<ContactOrManual[]>([]);
// UI state
let showDeleteConfirm = $state(false);
let isLoading = $state(false);
let contactsAvailable = $state<boolean | null>(null);
/**
* Initialize all form fields from a Task object.
* Call this when the task changes or the form becomes visible.
*/
function initFromTask(task: Task) {
title = task.title || '';
description = task.description || '';
dueDate = task.dueDate ? format(new Date(task.dueDate), 'yyyy-MM-dd') : '';
dueTime = task.dueTime || '';
startDate = task.startDate ? format(new Date(task.startDate), 'yyyy-MM-dd') : '';
priority = task.priority || 'medium';
status = task.status || 'pending';
projectId = task.projectId || null;
selectedLabelIds = task.labels?.map((l) => l.id) || [];
subtasks = task.subtasks ? [...task.subtasks] : [];
recurrenceRule = task.recurrenceRule || '';
notes = task.metadata?.notes || '';
storyPoints = task.metadata?.storyPoints ?? null;
effectiveDuration = task.metadata?.effectiveDuration ?? null;
funRating = task.metadata?.funRating ?? null;
assignee = task.metadata?.assignee ? [task.metadata.assignee] : [];
involvedContacts = task.metadata?.involvedContacts || [];
showDeleteConfirm = false;
// Check contacts availability
contactsStore.checkAvailability().then((available) => {
contactsAvailable = available;
});
}
/**
* Extract ContactReference from ContactOrManual (filter out manual entries).
*/
function toContactReference(contact: ContactOrManual): ContactReference | null {
if ('isManual' in contact && contact.isManual) {
return null;
}
return contact as ContactReference;
}
/**
* Build an UpdateTaskInput object from the current form state.
* Requires the original task for metadata merging.
*/
function buildUpdateInput(task: Task): UpdateTaskInput {
const assigneeRef = assignee.length > 0 ? toContactReference(assignee[0]) : null;
const involvedRefs = involvedContacts
.map(toContactReference)
.filter((c): c is ContactReference => c !== null);
return {
title: title.trim(),
description: description.trim() || null,
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
dueTime: dueTime || null,
startDate: startDate ? new Date(startDate).toISOString() : null,
priority,
status,
projectId: projectId || null,
subtasks: subtasks.length > 0 ? subtasks : null,
recurrenceRule: recurrenceRule || null,
metadata: {
...task.metadata,
notes: notes.trim() || undefined,
storyPoints: storyPoints ?? undefined,
effectiveDuration: effectiveDuration ?? undefined,
funRating: funRating ?? undefined,
assignee: assigneeRef ?? undefined,
involvedContacts: involvedRefs.length > 0 ? involvedRefs : undefined,
},
labelIds: selectedLabelIds,
};
}
return {
// State — exposed as getters/setters so bind:value works via form.title etc.
get title() {
return title;
},
set title(v: string) {
title = v;
},
get description() {
return description;
},
set description(v: string) {
description = v;
},
get dueDate() {
return dueDate;
},
set dueDate(v: string) {
dueDate = v;
},
get dueTime() {
return dueTime;
},
set dueTime(v: string) {
dueTime = v;
},
get startDate() {
return startDate;
},
set startDate(v: string) {
startDate = v;
},
get priority() {
return priority;
},
set priority(v: TaskPriority) {
priority = v;
},
get status() {
return status;
},
set status(v: TaskStatus) {
status = v;
},
get projectId() {
return projectId;
},
set projectId(v: string | null) {
projectId = v;
},
get selectedLabelIds() {
return selectedLabelIds;
},
set selectedLabelIds(v: string[]) {
selectedLabelIds = v;
},
get subtasks() {
return subtasks;
},
set subtasks(v: Subtask[]) {
subtasks = v;
},
get recurrenceRule() {
return recurrenceRule;
},
set recurrenceRule(v: string) {
recurrenceRule = v;
},
get notes() {
return notes;
},
set notes(v: string) {
notes = v;
},
get storyPoints() {
return storyPoints;
},
set storyPoints(v: number | null) {
storyPoints = v;
},
get effectiveDuration() {
return effectiveDuration;
},
set effectiveDuration(v: EffectiveDuration | null) {
effectiveDuration = v;
},
get funRating() {
return funRating;
},
set funRating(v: number | null) {
funRating = v;
},
get assignee() {
return assignee;
},
set assignee(v: ContactOrManual[]) {
assignee = v;
},
get involvedContacts() {
return involvedContacts;
},
set involvedContacts(v: ContactOrManual[]) {
involvedContacts = v;
},
get showDeleteConfirm() {
return showDeleteConfirm;
},
set showDeleteConfirm(v: boolean) {
showDeleteConfirm = v;
},
get isLoading() {
return isLoading;
},
set isLoading(v: boolean) {
isLoading = v;
},
get contactsAvailable() {
return contactsAvailable;
},
set contactsAvailable(v: boolean | null) {
contactsAvailable = v;
},
// Functions
initFromTask,
buildUpdateInput,
toContactReference,
};
}