Files
famdone/frontend/src/routes/[fam]/[username]/chores/+page.svelte
T
2026-08-31 19:26:55 +01:00

1615 lines
42 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { choresIcon, todoIcon, checkCircleIcon } from '$lib/components/icons';
import { formatHumanDate } from '$lib/format';
import { nextPaydayAfter, todayInTz, resolveTz } from '@shared/timezone';
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
// ── Chevron SVG icons ──
const CHEVRON_DOWN =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
const CHEVRON_UP =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
let { data, form } = $props();
let showEditModal = $state(false);
let showTodoModal = $state(false);
let editingAssignment = $state<AssignedChore | null>(null);
let editMemberId = $state('');
let draggedTemplateId = $state<string | null>(null);
let todoMemberId = $state('');
// Assign-from-template modal (platform templates are copied into the family
// as assigned chores; the fam admin amends before saving).
let assignModal = $state<{ t: ChoreTemplate; memberId: string } | null>(null);
let assignName = $state('');
let assignDescription = $state('');
let assignColor = $state('');
let assignIcon = $state('');
let assignFreq = $state('daily');
let assignType = $state('points');
let assignValue = $state(10);
let assignSeasonIds = $state<string[]>([]);
// Todo form state
let todoName = $state('');
let todoType = $state<'points' | 'money' | 'emoji'>('points');
let todoValue = $state(10);
let todoEmoji = $state('🎉');
let showTodoEmojiPicker = $state(false);
let todoStart = $state<'now' | 'next-week'>('now');
let todoCompleteBy = $state<'next-week' | 'custom'>('next-week');
let todoCustomDate = $state('');
// Curated celebration emojis for todos
const EMOJI_CHOICES = [
'🎉',
'⭐',
'🏆',
'💪',
'🧹',
'📚',
'🦸',
'✨',
'🌟',
'🥇',
'🎯',
'💚',
'🚀',
'🦄',
'🌈',
'🍀',
'🎨',
'🎸',
'⚽',
'🍰'
];
let editFreq = $state('daily');
let editType = $state('points');
let editValue = $state(10);
let editCustomName = $state('');
let editDescription = $state('');
let editColor = $state('');
let editIcon = $state('');
let editEmoji = $state('🎉');
let editTodoCompleteBy = $state('');
let editTodoStartDate = $state('');
let seasonFilter = $state('all');
let editSeasonIds = $state<string[]>([]);
// Platform-owned templates only — families assign these, they don't create.
let templates = $derived(
(
(famStore.initialized
? (famStore.templates as ChoreTemplate[])
: (data.templates as ChoreTemplate[])) || []
).filter((t: ChoreTemplate) => t.global)
);
let members = $state(
famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[]) || []
);
let assigned = $derived(
famStore.initialized
? (famStore.assigned as AssignedChore[])
: (data.assigned as AssignedChore[]) || []
);
let seasons = $state(famStore.initialized ? famStore.seasons : data.seasons || []);
// Only active seasons are selectable for assignment / filtering.
let activeSeasons = $derived((seasons || []).filter((s) => s.active !== false));
let completions = $state((data.completions as Completion[]) || []);
// $effect(() => console.log(seasons, activeSeasons));
let filteredAssigned = $derived(
seasonFilter === 'all' ? assigned : assigned.filter((a) => a.seasonIds?.includes(seasonFilter))
);
function assignedForMember(memberId: string): AssignedChore[] {
return filteredAssigned.filter((a) => a.memberId === memberId && !a.isTodo);
}
function todosForMember(memberId: string): AssignedChore[] {
return filteredAssigned.filter((a) => a.memberId === memberId && a.isTodo);
}
function assignedForMemberAll(memberId: string): AssignedChore[] {
return assigned.filter((a) => a.memberId === memberId && !a.isTodo);
}
function isGlobalChore(a: AssignedChore): boolean {
return !a.seasonIds || a.seasonIds.length === 0;
}
function templateName(templateId: string | undefined): string {
if (!templateId) return '?';
return famStore.templateMap().get(templateId)?.name || '?';
}
function seasonName(id: string): string {
return seasons.find((s) => s.id === id)?.name || id;
}
function seasonColor(id: string): string {
return seasons.find((s) => s.id === id)?.color || '#9ca3af';
}
function seasonNames(a: AssignedChore): string {
return (a.seasonIds || []).map((id) => seasonName(id)).join(', ') || 'Global';
}
function getDefault(templateId: string): { frequency: string; type: string; value: number } {
const t = famStore.templateMap().get(templateId);
if (!t) return { frequency: 'daily', type: 'points', value: 10 };
return { frequency: t.defaultFrequency, type: t.defaultType, value: t.defaultValue };
}
function handleDragStart(e: DragEvent, templateId: string) {
draggedTemplateId = templateId;
e.dataTransfer?.setData('text/plain', templateId);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
}
async function handleDrop(e: DragEvent, memberId: string) {
e.preventDefault();
const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId;
draggedTemplateId = null;
if (!tid) return;
const t = templates.find((x) => x.id === tid);
if (!t) return;
assignModal = { t, memberId };
assignName = t.name || '';
assignDescription = t.description || '';
assignColor = t.color || '';
assignIcon = t.icon || '';
assignFreq = t.defaultFrequency;
assignType = t.defaultType;
assignValue = t.defaultValue;
assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : [];
}
async function assignChore() {
if (!assignModal) return;
const s = page.data.session as any;
if (!s) return;
const body: Record<string, unknown> = {
memberId: assignModal.memberId,
templateId: assignModal.t.id,
customName: assignName,
description: assignDescription,
color: assignColor,
icon: assignIcon,
frequency: assignFreq,
type: assignType,
value: assignValue
};
if (assignSeasonIds.length) body.seasonIds = assignSeasonIds;
await fetch(`/api/admin/${s.famId}/assigned-chores`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-session-famid': s.famId,
'x-session-userid': s.userId
},
body: JSON.stringify(body)
});
assignModal = null;
}
// Accordion state per member: which section is open
let accordionState = $state<Record<string, { chores: boolean; todos: boolean }>>({});
function toggleAccordion(memberId: string, section: 'chores' | 'todos') {
const cur = accordionState[memberId] ?? { chores: true, todos: true };
accordionState[memberId] = { ...cur, [section]: !cur[section] };
}
function openTodo(memberId: string) {
todoMemberId = memberId;
todoName = '';
todoType = 'points';
todoValue = 10;
todoEmoji = '🎉';
showTodoEmojiPicker = false;
todoStart = 'now';
todoCompleteBy = 'custom';
todoCustomDate = defaultCompleteByDate();
showTodoModal = true;
}
// Sort todos by completeBy (soonest first), then by startDate
function todoSort(a: AssignedChore, b: AssignedChore): number {
const da = a.completeBy || '9999-99-99';
const db = b.completeBy || '9999-99-99';
if (da !== db) return da.localeCompare(db);
return (a.startDate || '').localeCompare(b.startDate || '');
}
// Family payday anchor + resolved tz for week-boundary maths.
const famPayday = $derived(
(famStore.fam as any as { payday?: number })?.payday != null
? Number((famStore.fam as any).payday)
: 1
);
const famTz = $derived(resolveTz((famStore.fam as any)?.timezone || 'auto'));
// A completed todo stays visible (green "TBC completed") for the rest of the
// week it was completed; once the next payday rolls around (a new week
// starts) it drops off the admin Todos list.
function comprisesTodo(todo: AssignedChore): boolean {
if (!isTodoCompleted(todo.id)) return false;
const completion = completions.find((c: Completion) => c.assignedChoreId === todo.id);
const done = (completion?.date || '').slice(0, 10);
if (!done) return true;
const nextPayday = nextPaydayAfter(done, famPayday, famTz);
return todayInTz(famTz) >= nextPayday;
}
// All todos shown for a member — but once the week they were completed in is
// over (payday has passed) the completed todo is hidden from the list.
function visibleTodosForMember(memberId: string): AssignedChore[] {
return todosForMember(memberId)
.filter((a) => !comprisesTodo(a))
.sort(todoSort);
}
// 7 days from today as YYYY-MM-DD for the todo modal default
function defaultCompleteByDate(): string {
const d = new Date();
d.setDate(d.getDate() + 7);
return d.toISOString().slice(0, 10);
}
// Check if a todo has been completed by the child (todos don't reset daily like chores)
function isTodoCompleted(todoId: string): boolean {
return completions.some((c: Completion) => c.assignedChoreId === todoId);
}
// Traffic light for admin todo cards (same logic as child page)
function todoUrgency(a: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
if (!a.completeBy) return 'blue';
const today = new Date().toISOString().slice(0, 10);
const days = Math.round(
(new Date(a.completeBy + 'T00:00:00').getTime() - new Date(today + 'T00:00:00').getTime()) /
86400000
);
if (days <= 0) return 'black';
if (days === 1) return 'red';
if (days === 2) return 'orange';
if (days === 3) return 'green';
return 'blue';
}
function openEdit(a: AssignedChore) {
editingAssignment = a;
editFreq = a.frequency;
editType = a.type;
editValue = a.value;
editCustomName = a.customName || '';
editDescription = a.description || '';
editColor = a.color || '';
editIcon = a.icon || '';
editEmoji = a.emoji || '🎉';
editSeasonIds = a.seasonIds ? [...a.seasonIds] : [];
editTodoCompleteBy = a.completeBy || '';
editTodoStartDate = a.startDate || '';
showEditModal = true;
}
function closeEdit() {
showEditModal = false;
editingAssignment = null;
}
function enhanceUpdateAssigned(e: any) {
const data = e?.data;
if (data && editingAssignment && !editingAssignment.isTodo) {
data.set('seasonIds', editSeasonIds.join(','));
data.set('color', editColor);
data.set('icon', editIcon);
data.set('description', editDescription);
}
return async ({ result }: any) => {
if (result.type === 'success' && editingAssignment) {
const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id);
if (idx !== -1) {
const updates: Partial<AssignedChore> = {
customName: editCustomName || undefined
};
if (editingAssignment.isTodo) {
updates.type = editType;
updates.value = editType === 'emoji' ? 0 : editValue;
updates.emoji = editType === 'emoji' ? editEmoji : undefined;
updates.completeBy = editTodoCompleteBy;
updates.startDate = editTodoStartDate;
} else {
updates.frequency = editFreq;
updates.type = editType;
updates.value = editValue;
updates.seasonIds = editSeasonIds.length > 0 ? editSeasonIds : [];
}
assigned[idx] = { ...assigned[idx], ...updates } as AssignedChore;
}
}
closeEdit();
};
}
</script>
<ViewHeader title="Chore Assignment" hero />
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<CardGrid stackTablet>
<!-- Templates Section (at top, like bonuses) -->
<Card cols={3}>
<div class="template-section">
<div class="template-header">
<h3>Templates — Drag to assign</h3>
</div>
<div class="template-list">
{#each templates as t}
<div
class="card template"
draggable="true"
ondragstart={(e) => handleDragStart(e, t.id)}
style="border-left-color:{outlineColor(t.color)}; background:{accentBg(t.color)}"
>
<div class="card-body">
<TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} />
<strong>{t.name}</strong>
<span class="badge">{t.defaultFrequency}</span>
<span class="badge type">{t.defaultType}</span>
</div>
<div class="card-value">
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
</div>
</div>
{/each}
{#if templates.length === 0}
<p class="empty">No templates yet</p>
{/if}
</div>
</div>
</Card>
<!-- Member Chore Assignment Kanban (3-column grid) -->
<Card cols={3} scrollX>
<div class="kanban-toolbar">
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each activeSeasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
</div>
<div class="kanban">
{#each members as m}
<div
class="column member-col"
style="border-top-color: {m.color}; --mc: {m.color}"
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)}
>
<h3>
<span class="dot" style="background:{m.color}"></span>
{m.name}
</h3>
<!-- TODOS accordion wrapper -->
<div
class="accordion-wrapper accordion-todos"
class:open={accordionState[m.id]?.todos ?? true}
>
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
<span class="accordion-title">
<span class="accordion-icon">{@html todoIcon}</span>
<span class="accordion-label">Todos</span>
</span>
<span class="accordion-chevron"
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
<span class="accordion-count" style="background:{m.color}"
>{visibleTodosForMember(m.id).length}</span
>
{#if accordionState[m.id]?.todos ?? true}
<div class="accordion-body">
{#each visibleTodosForMember(m.id) as a}
{@const completed = isTodoCompleted(a.id)}
{@const urgency = todoUrgency(a)}
<div
class="todo-admin-card"
class:todo-completed={completed}
class:todo-blue-bg={!completed && urgency === 'blue'}
class:todo-green-bg={!completed && urgency === 'green'}
class:todo-orange-bg={!completed && urgency === 'orange'}
class:todo-red-bg={!completed && urgency === 'red'}
class:todo-black-bg={!completed && urgency === 'black'}
onclick={() => !completed && openEdit(a)}
role="button"
tabindex={completed ? -1 : 0}
>
<div class="todo-admin-body">
<strong class="todo-admin-name">{a.customName || 'Todo'}</strong>
<div class="todo-admin-meta">
{#if a.type === 'emoji'}
<span class="todo-admin-type">{a.emoji || '🎉'} celebration</span>
{:else if a.type === 'money'}
<span class="todo-admin-type">£{Number(a.value).toFixed(2)} cash</span>
{:else}
<span class="todo-admin-type"
><span class="badge badge-pts">{a.value} pts</span></span
>
{/if}
{#if a.completeBy}
<span class="todo-admin-deadline"
>due {formatHumanDate(a.completeBy)}</span
>
{/if}
</div>
</div>
{#if completed}
<span class="todo-completed-badge">{@html checkCircleIcon} TBC completed</span
>
{:else}
<button
class="del-btn"
title="Remove todo"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
if (!s) return;
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
method: 'DELETE',
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
});
}}>×</button
>
{/if}
</div>
{/each}
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}
>+ Add a todo</button
>
</div>
{/if}
</div>
<!-- CHORES accordion wrapper -->
<div
class="accordion-wrapper accordion-chores"
class:open={accordionState[m.id]?.chores ?? true}
>
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-title">
<span class="accordion-icon">{@html choresIcon}</span>
<span class="accordion-label">Chores</span>
</span>
<span class="accordion-chevron"
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
<span class="accordion-count" style="background:{m.color}"
>{assignedForMember(m.id).length}</span
>
{#if accordionState[m.id]?.chores ?? true}
<div class="accordion-body">
{#each assignedForMember(m.id) as a}
{@const tName = templateName(a.templateId)}
<div
class="card assigned"
onclick={() => openEdit(a)}
role="button"
tabindex="0"
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(
a.color
)}"
>
<div class="card-body">
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
{#each a.seasonIds || [] as sid}
<span
class="season-dot"
title={seasonName(sid)}
style="background:{seasonColor(sid)}"
></span>
{/each}
{#if (a.seasonIds || []).length === 0}
<span class="badge season muted">All year</span>
{/if}
</div>
<div class="card-value">
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
</div>
<button
class="del-btn"
title="Remove assignment"
onclick={async (e) => {
e.stopPropagation();
const s = page.data.session as any;
if (!s) return;
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
method: 'DELETE',
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
});
}}>×</button
>
</div>
{/each}
{#if assignedForMember(m.id).length === 0}
<p class="empty">Drop a chore here</p>
{/if}
</div>
{/if}
</div>
</div>
{/each}
{#if members.length < 3}
{#each Array(3 - members.length) as _}
<div class="column column-placeholder"></div>
{/each}
{/if}
</div>
</Card>
</CardGrid>
<!-- Season pill selector (reused by assign + edit modals) -->
{#snippet seasonPills(list: Season[] = [], selected: string[] = [], set: (v: string[]) => void)}
<div class="season-pills" role="radiogroup" aria-label="Seasons">
<button
type="button"
class="season-pill"
class:selected={selected.length === 0}
style="--pill:#9ca3af"
role="radio"
aria-checked={selected.length === 0}
onclick={() => set([])}>Any</button
>
{#each list as s}
<button
type="button"
class="season-pill"
class:selected={selected[0] === s.id}
style="--pill:{s.color}"
role="radio"
aria-checked={selected[0] === s.id}
onclick={() => set([s.id])}>{s.name}</button
>
{/each}
</div>
{/snippet}
<!-- Assign from Template Modal -->
{#if assignModal}
<div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) assignModal = null;
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Assign: {assignModal.t.name}</h3>
<p class="hint">
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)
?.name}'s chores. Amend, then Assign.
</p>
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
<label
>Description <input
bind:value={assignDescription}
placeholder="Optional description"
/></label
>
<div class="color-field">
<span class="field-label">Colour</span>
<div class="color-swatches">
{#each TEMPLATE_COLORS as c}
<button
type="button"
class="swatch"
class:selected={assignColor === c.value}
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
'transparent'
? '#e5e7eb'
: c.value}"
title={c.label}
onclick={() => (assignColor = c.value)}
></button>
{/each}
</div>
</div>
<label class="icon-row"
><span class="icon-preview"
><TemplateIcon name={assignIcon} size={22} color={outlineColor(assignColor)} /></span
>
<select bind:value={assignIcon}>
<option value="">None</option>
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
</select>
</label>
<label>
Frequency
<select bind:value={assignFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select bind:value={assignType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Value
<input type="number" step="any" bind:value={assignValue} />
</label>
<div class="season-field">
<span class="field-label">Seasons</span>
{@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
</div>
<div class="modal-actions">
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
<button type="button" onclick={assignChore}>Assign</button>
</div>
</div>
</div>
{/if}
<!-- Edit Assignment Modal -->
{#if showEditModal && editingAssignment}
<div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) closeEdit();
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>
{#if editingAssignment.isTodo}
Edit Todo: {editingAssignment.customName || 'Todo'}
{:else}
Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}
{/if}
</h3>
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
<input name="id" type="hidden" value={editingAssignment.id} />
<input name="isTodo" type="hidden" value={editingAssignment.isTodo ? '1' : '0'} />
<label>
Name
<input name="customName" bind:value={editCustomName} placeholder="Name" />
</label>
<label
>Description
<input
name="description"
bind:value={editDescription}
placeholder="Optional description"
/></label
>
{#if editingAssignment.isTodo}
<label>
Type
<select name="type" bind:value={editType}>
<option value="points">Points</option>
<option value="money">Cash</option>
<option value="emoji">Celebration</option>
</select>
</label>
{#if editType === 'points'}
<label>
Points value
<input name="value" type="number" bind:value={editValue} required />
</label>
{:else if editType === 'money'}
<label>
Cash amount (£)
<input
name="value"
type="number"
step="0.01"
min="0.01"
bind:value={editValue}
required
/>
</label>
{:else}
<input type="hidden" name="emoji" value={editEmoji} />
<div class="emoji-field">
<span class="emoji-preview">{editEmoji}</span>
<button
type="button"
class="link-btn"
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
>
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
</button>
</div>
{#if showTodoEmojiPicker}
<div class="emoji-grid">
{#each EMOJI_CHOICES as e}
<button
type="button"
class="emoji-opt"
class:selected={e === editEmoji}
onclick={() => {
editEmoji = e;
showTodoEmojiPicker = false;
}}>{e}</button
>
{/each}
</div>
{/if}
{/if}
<label>
Start date
<input name="startDate" type="date" bind:value={editTodoStartDate} />
</label>
<label>
Complete by
<input name="completeBy" type="date" bind:value={editTodoCompleteBy} />
</label>
{:else}
<input name="seasonIds" type="hidden" value={editSeasonIds.join(',')} />
<div class="color-field">
<span class="field-label">Colour</span>
<div class="color-swatches">
{#each TEMPLATE_COLORS as c}
<button
type="button"
class="swatch"
class:selected={editColor === c.value}
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
'transparent'
? '#e5e7eb'
: c.value}"
title={c.label}
onclick={() => (editColor = c.value)}
></button>
{/each}
</div>
<input type="hidden" name="color" value={editColor} />
</div>
<label class="icon-row"
><span class="icon-preview"
><TemplateIcon name={editIcon} size={22} color={outlineColor(editColor)} /></span
>
<select name="icon" bind:value={editIcon}>
<option value="">None</option>
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
</select>
</label>
<label>
Frequency
<select name="frequency" bind:value={editFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="type" bind:value={editType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Value
<input name="value" type="number" step="any" bind:value={editValue} required />
</label>
<div class="season-field">
<span class="field-label">Seasons</span>
{@render seasonPills(seasons, editSeasonIds, (v: string[]) => (editSeasonIds = v))}
</div>
{/if}
<div class="modal-actions">
<button type="button" onclick={closeEdit}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Add Todo Modal -->
{#if showTodoModal && todoMemberId}
<div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) showTodoModal = false;
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Add Todo for {members.find((m) => m.id === todoMemberId)?.name || 'Member'}</h3>
<form
method="POST"
action="?/createTodo"
use:enhance={() => {
return async ({ result }: any) => {
if (result.type === 'success') {
showTodoModal = false;
todoMemberId = '';
}
};
}}
>
<input type="hidden" name="memberId" value={todoMemberId} />
<input
type="hidden"
name="startDate"
value={todoStart === 'now' ? new Date().toISOString().slice(0, 10) : ''}
/>
<input
type="hidden"
name="completeBy"
value={todoCompleteBy === 'custom' ? todoCustomDate : ''}
/>
<label>
Name
<input name="name" bind:value={todoName} required placeholder="What needs doing?" />
</label>
<label>
Type
<select name="type" bind:value={todoType}>
<option value="points">Points</option>
<option value="money">Cash</option>
<option value="emoji">Celebration</option>
</select>
</label>
{#if todoType === 'points'}
<label>
Points value
<input name="value" type="number" bind:value={todoValue} required />
</label>
{:else if todoType === 'money'}
<label>
Cash amount (£)
<input
name="value"
type="number"
step="0.01"
min="0.01"
bind:value={todoValue}
required
/>
</label>
{:else}
<input type="hidden" name="emoji" value={todoEmoji} />
<div class="emoji-field">
<span class="emoji-preview">{todoEmoji}</span>
<button
type="button"
class="link-btn"
onclick={() => (showTodoEmojiPicker = !showTodoEmojiPicker)}
>
{showTodoEmojiPicker ? 'Hide' : 'Choose'}
</button>
</div>
{#if showTodoEmojiPicker}
<div class="emoji-grid">
{#each EMOJI_CHOICES as e}
<button
type="button"
class="emoji-opt"
class:selected={e === todoEmoji}
onclick={() => {
todoEmoji = e;
showTodoEmojiPicker = false;
}}>{e}</button
>
{/each}
</div>
{/if}
{/if}
<label>
Starts
<select name="todoStart" bind:value={todoStart}>
<option value="now">Now</option>
<option value="next-week">Next week</option>
</select>
</label>
<label>
Complete by
<select name="todoCompleteBy" bind:value={todoCompleteBy}>
<option value="next-week">Next week</option>
<option value="custom">Custom date</option>
</select>
</label>
{#if todoCompleteBy === 'custom'}
<label>
Due date
<input name="customDate" type="date" bind:value={todoCustomDate} required />
</label>
{/if}
<div class="modal-actions">
<button type="button" onclick={() => (showTodoModal = false)}>Cancel</button>
<button type="submit">Add Todo</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.error {
color: #dc2626;
padding: 0.5rem;
background: #fef2f2;
border-radius: 4px;
margin: 0.5rem 0;
}
/* ── Template section (top, like bonuses) ── */
.template-section {
margin-bottom: 0;
}
.template-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.template-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.template-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.template-list .card.template {
flex: 0 1 auto;
min-width: 150px;
}
.template-list .card.template .card-body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
@media (max-width: 1023px) {
.template-list {
flex-direction: column;
}
.template-list .card.template {
width: 100%;
}
}
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
min-width: 450px;
}
.column {
border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1;
border-radius: 12px;
padding: 0.75rem;
background: #fafbff;
min-height: 200px;
}
.column h3 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.empty {
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1.5rem;
}
.add-inline {
display: block;
width: 100%;
margin-bottom: 0.35rem;
padding: 0.45rem;
background: rgba(99, 102, 241, 0.04);
border: 1px dashed #c7d2fe;
border-radius: 8px;
color: #6366f1;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
text-align: center;
}
.template-section > .add-inline {
margin-bottom: 0;
}
.accordion-add {
margin-bottom: 0;
margin-top: 0.35rem;
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
}
.accordion-add:hover {
background: rgba(255, 255, 255, 0.35);
border-color: rgba(255, 255, 255, 0.6);
color: #fff;
}
.add-inline:hover {
border-color: #6366f1;
background: #eef2ff;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-left: 4px solid #8b5cf6;
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.4rem;
display: flex;
align-items: center;
gap: 0.5rem;
position: relative;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.card:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
}
.card-body {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
.card-body strong {
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #1f2937;
}
.card-value {
font-size: 0.8rem;
color: #6b7280;
font-weight: 600;
white-space: nowrap;
}
.badge {
font-size: 0.7rem;
background: #e0e7ff;
color: #4338ca;
padding: 1px 6px;
border-radius: 10px;
margin-right: 4px;
}
.badge.type {
background: #fce7f3;
color: #be185d;
}
.card-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.edit-btn,
.del-btn {
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 1rem;
padding: 2px 6px;
line-height: 1;
flex-shrink: 0;
border-radius: 4px;
transition:
color 0.15s,
background 0.15s;
}
.edit-btn:hover {
color: #6366f1;
background: #eef2ff;
}
.del-btn:hover {
color: #dc2626;
background: #fef2f2;
}
.hint {
color: #6b7280;
font-size: 0.85rem;
margin-bottom: 1rem;
}
.season-filter {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kanban-toolbar {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.season-filter label {
font-size: 0.8rem;
color: #6b7280;
font-weight: 500;
}
.season-filter select {
padding: 0.3rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.8rem;
background: #fff;
}
.badge.season {
background: transparent;
border: 1px solid #0369a1;
color: #0369a1;
}
.badge.season.muted {
color: #9ca3af;
border-color: #e5e7eb;
}
.season-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
flex: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.card-meta {
font-size: 0.7rem;
color: #9ca3af;
flex-shrink: 0;
}
.season-tags {
font-size: 0.7rem;
color: #9ca3af;
}
.season-checklist {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.25rem;
}
.check-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
cursor: pointer;
}
.check-item input[type='checkbox'] {
width: auto;
margin: 0;
}
.color-field,
.season-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.75rem;
}
.field-label {
font-size: 0.85rem;
font-weight: 600;
color: #6b7280;
}
.modal label.icon-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.modal label.icon-row select {
flex: 1;
}
.icon-preview {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
}
.color-swatches {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid #e5e7eb;
cursor: pointer;
padding: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.swatch.selected {
outline: 2px solid #111827;
outline-offset: 1px;
}
.season-pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.season-pill {
padding: 0.3rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--pill, #e5e7eb);
background: transparent;
color: #374151;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.12s ease;
}
.season-pill.selected {
background: var(--pill, #8b5cf6);
color: #fff;
border-color: var(--pill, #8b5cf6);
}
.template {
cursor: grab;
border-left: 4px solid #8b5cf6;
}
.template:active {
cursor: grabbing;
opacity: 0.8;
}
.assigned {
cursor: pointer;
border-left: 4px solid #10b981;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: white;
border-radius: 12px;
padding: 1.5rem;
min-width: 320px;
max-width: 440px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.modal h3 {
margin: 0 0 1rem;
}
.modal label {
display: block;
margin-bottom: 0.75rem;
font-size: 0.9rem;
color: #374151;
}
.modal input,
.modal select {
display: block;
width: 100%;
margin-top: 0.25rem;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
box-sizing: border-box;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
.modal-actions button {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.modal-actions button[type='button'] {
background: #e5e7eb;
color: #374151;
}
.modal-actions button[type='submit'] {
background: #6366f1;
color: white;
}
.modal-actions button.delete-btn {
background: #dc2626;
color: white;
}
/* ── Accordion wrappers ── */
.accordion-wrapper {
position: relative;
margin: 0.75rem 0 0.3rem;
}
.accordion-wrapper:first-child {
margin-top: 1.1rem;
}
.accordion-head {
width: 100%;
padding: 0.55rem 0.85rem;
border: none;
border-left: 4px solid rgba(0, 0, 0, 0.2);
background: var(--mc, #6366f1);
border-radius: 10px;
cursor: pointer;
font-size: 0.82rem;
font-weight: 700;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
display: flex;
justify-content: space-between;
align-items: center;
transition: filter 0.15s;
}
.accordion-wrapper.accordion-todos .accordion-head {
background: oklch(from var(--mc, #6366f1) calc(l + 0.08) c h);
}
.accordion-wrapper.open .accordion-head {
border-radius: 10px 10px 0 0;
}
.accordion-head:hover {
filter: brightness(1.1);
}
.accordion-body {
padding: 0.5rem 0.65rem 0.65rem;
background: oklch(from var(--mc, #6366f1) calc(l - 0.1) c h);
border-radius: 0 0 10px 10px;
}
.accordion-wrapper.accordion-todos .accordion-body {
background: oklch(from var(--mc, #6366f1) calc(l - 0.02) c h);
}
.accordion-chevron {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.8;
}
.accordion-icon {
display: inline-flex;
align-items: center;
}
.accordion-title {
display: flex;
align-items: center;
gap: 0.4rem;
}
.accordion-label {
font-size: 0.82rem;
font-weight: 700;
color: #fff;
}
.accordion-count {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: #6366f1;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
z-index: 2;
}
/* ── Todo admin cards ── */
.todo-admin-card {
background: white;
border: 1px solid #e5e7eb;
border-left: 4px solid #3b82f6;
border-radius: 8px;
padding: 0.45rem 0.65rem;
margin-bottom: 0.35rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.4rem;
transition:
transform 0.15s,
box-shadow 0.15s,
opacity 0.2s;
}
.todo-admin-card:hover {
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
}
.todo-admin-card.todo-completed {
opacity: 0.55;
cursor: default;
background: #f0fdf4;
border-left-color: #10b981;
}
.todo-admin-card.todo-completed:hover {
transform: none;
box-shadow: none;
}
.todo-blue-bg {
background: #eff6ff;
border-left-color: #3b82f6;
}
.todo-green-bg {
background: #f0fdf4;
border-left-color: #22c55e;
}
.todo-orange-bg {
background: #fff7ed;
border-left-color: #f97316;
}
.todo-red-bg {
background: #fef2f2;
border-left-color: #ef4444;
}
.todo-black-bg {
background: #f9fafb;
border-left-color: #1f2937;
}
.todo-admin-body {
flex: 1;
min-width: 0;
}
.todo-admin-name {
display: block;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.todo-admin-meta {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.7rem;
margin-top: 0.15rem;
}
.emoji-field {
display: flex;
align-items: center;
gap: 0.5rem;
}
.emoji-preview {
font-size: 1.4rem;
}
.emoji-grid {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.4rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.emoji-opt {
font-size: 1.2rem;
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 0.15rem 0.3rem;
cursor: pointer;
}
.emoji-opt:hover {
background: #f3f4f6;
}
.emoji-opt.selected {
border-color: #6366f1;
background: #eef2ff;
}
.todo-admin-type {
color: #6b7280;
font-weight: 600;
}
.todo-admin-deadline {
color: #f59e0b;
font-weight: 700;
}
.todo-completed-badge {
font-size: 0.65rem;
font-weight: 700;
color: #059669;
background: #d1fae5;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 3px;
}
.todo-completed-badge :global(svg) {
width: 0.95em;
height: 0.95em;
}
/* ── Points badge ── */
.badge-pts {
background: #e0e7ff;
color: #4338ca;
font-size: 0.68rem;
padding: 1px 6px;
border-radius: 10px;
font-weight: 600;
}
/* ── Placeholder column ── */
.column-placeholder {
border-top-color: #e5e7eb !important;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 8px,
rgba(0, 0, 0, 0.02) 8px,
rgba(0, 0, 0, 0.02) 16px
);
border-style: dashed;
opacity: 0.5;
min-height: 200px;
}
</style>