Files
famdone/frontend/src/routes/[fam]/[username]/chores/+page.svelte
T
2026-09-15 09:05:13 +01:00

1918 lines
51 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 assignStep = $state(1);
let assignName = $state('');
let assignDescription = $state('');
let assignTarget = $state<'member' | 'shared'>('member');
let assignColor = $state('');
let assignIcon = $state('');
let assignFreq = $state('daily');
let assignType = $state('points');
let assignValue = $state(10);
let assignSeasonIds = $state<string[]>([]);
const STEP_SVG = (n: number) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><text x="12" y="16" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor" stroke="none">${n}</text></svg>`;
// 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 sharedChoresFiltered(): AssignedChore[] {
return filteredAssigned.filter((a) => !a.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 };
assignStep = 1;
assignTarget = memberId ? 'member' : 'shared';
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 isShared = assignTarget === 'shared';
const body: Record<string, unknown> = {
memberId: isShared ? '' : assignModal.memberId,
templateId: assignModal.t?.id || '',
customName: assignName,
description: assignDescription,
color: assignColor,
icon: assignIcon,
frequency: assignFreq,
type: assignType,
value: assignValue,
shared: isShared
};
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;
assignStep = 1;
}
// 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;
}
function openCreateChore(memberId = '') {
const target = memberId || members[0]?.id || '';
assignModal = { t: null as any, memberId: target };
assignStep = 1;
assignTarget = target ? 'member' : 'shared';
assignName = '';
assignDescription = '';
assignColor = '';
assignIcon = '';
assignFreq = 'daily';
assignType = 'points';
assignValue = 10;
assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : [];
}
// 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);
}
// A todo whose completeBy has passed without being completed is expired —
// no longer doable for the rest of the week.
function isTodoExpired(a: AssignedChore): boolean {
if (isTodoCompleted(a.id)) return false;
if (!a.completeBy) return false;
return a.completeBy < todayInTz(famTz);
}
// 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). Desktop only — dragging
needs pointer precision; below desktop each member card gets its own
"Create a chore" button instead. -->
<Card cols={3} class="chores-templates-card">
<div class="template-section">
<div class="template-header">
<h3>Templates — Drag to assign</h3>
<button class="add-inline" onclick={() => openCreateChore()}>+ Create a chore</button>
</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">
<span class="card-title-line">
<TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} />
<strong>{t.name}</strong>
</span>
<span class="card-badge-line">
<span class="badge">{t.defaultFrequency}</span>
<span class="badge type">{t.defaultType}</span>
</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 expired = isTodoExpired(a)}
{@const urgency = todoUrgency(a)}
<div
class="todo-admin-card"
class:todo-completed={completed}
class:todo-expired={expired && !completed}
class:todo-blue-bg={!completed && !expired && urgency === 'blue'}
class:todo-green-bg={!completed && !expired && urgency === 'green'}
class:todo-orange-bg={!completed && !expired && urgency === 'orange'}
class:todo-red-bg={!completed && !expired && urgency === 'red'}
class:todo-black-bg={!completed && !expired && urgency === 'black'}
onclick={() => !completed && !expired && openEdit(a)}
role="button"
tabindex={completed || expired ? -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 if expired}
<span class="todo-expired-badge">EXPIRED</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">
<span class="card-title-line">
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<strong>{a.customName || tName}</strong>
</span>
<span class="card-badge-line">
<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}
</span>
</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}
<button class="add-inline accordion-add" onclick={() => openCreateChore(m.id)}
>+ Create a chore</button
>
</div>
{/if}
</div>
</div>
{/each}
<!-- Shared column -->
<div
class="column member-col shared-col"
style="border-top-color: #6366f1; --mc: #6366f1"
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, '')}
>
<h3>
<span class="dot" style="background:#6366f1"></span>
Everyone
</h3>
<!-- CHORES accordion wrapper -->
<div
class="accordion-wrapper accordion-chores"
class:open={accordionState.shared?.chores ?? true}
>
<button class="accordion-head" onclick={() => toggleAccordion('shared', 'chores')}>
<span class="accordion-title">
<span class="accordion-icon">{@html choresIcon}</span>
<span class="accordion-label">Competitive Chores</span>
</span>
<span class="accordion-chevron"
>{@html (accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
>
</button>
<span class="accordion-count" style="background:#6366f1"
>{sharedChoresFiltered().length}</span
>
{#if accordionState.shared?.chores ?? true}
<div class="accordion-body">
{#each sharedChoresFiltered() 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">
<span class="card-title-line">
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<strong>{a.customName || tName}</strong>
</span>
<span class="card-badge-line">
<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}
</span>
</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>
{:else}
<p class="empty">Drop a chore here</p>
{/each}
<button class="add-inline accordion-add" onclick={() => openCreateChore('')}
>+ Create a chore</button
>
</div>
{/if}
</div>
</div>
{#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 (Who / How / What wizard) -->
{#if assignModal}
<div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) {
assignModal = null;
assignStep = 1;
}
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>{assignModal.t ? `Assign: ${assignModal.t.name}` : 'Create a chore'}</h3>
<!-- Step tabs -->
<div class="step-tabs" role="tablist">
<button class="step-tab" class:active={assignStep === 1} onclick={() => (assignStep = 1)}>
{@html STEP_SVG(1)}<span>Who</span>
</button>
<button class="step-tab" class:active={assignStep === 2} onclick={() => (assignStep = 2)}>
{@html STEP_SVG(2)}<span>How</span>
</button>
<button class="step-tab" class:active={assignStep === 3} onclick={() => (assignStep = 3)}>
{@html STEP_SVG(3)}<span>What</span>
</button>
</div>
{#if assignStep === 1}
<!-- Step 1: Who -->
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
<label
>Description <input
bind:value={assignDescription}
placeholder="Optional description"
/></label
>
<label>
Assign to
<select
bind:value={assignTarget}
onchange={() => {
if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
assignModal = { ...assignModal!, memberId: members[0].id };
}
}}
>
<option value="member">A specific member</option>
<option value="shared">Shared (all members)</option>
</select>
</label>
{#if assignTarget === 'member'}
<label>
Member
<select bind:value={assignModal.memberId}>
{#each members as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
{/if}
{:else if assignStep === 2}
<!-- Step 2: How -->
<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>
{:else}
<!-- Step 3: What -->
<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>
{/if}
<div class="modal-actions">
{#if assignStep > 1}
<button type="button" onclick={() => assignStep--}>Back</button>
{:else}
<button
type="button"
onclick={() => {
assignModal = null;
assignStep = 1;
}}>Cancel</button
>
{/if}
{#if assignStep < 3}
<button type="button" class="primary" onclick={() => assignStep++}>Next</button>
{:else}
<button type="button" class="primary" onclick={assignChore}>Assign</button>
{/if}
</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-direction: column;
align-items: flex-start;
gap: 0.15rem;
}
@media (max-width: 1023px) {
.template-list {
flex-direction: column;
}
.template-list .card.template {
width: 100%;
}
/* Below desktop there's no drag-and-drop — hide the templates strip
(each member/shared card carries its own Create button instead).
:global because the class lives on the Card component's root. */
:global(.chores-templates-card) {
display: none;
}
}
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban {
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding-bottom: 0.5rem;
align-items: flex-start;
}
.column {
flex: 0 0 450px;
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;
color: #4338ca;
}
.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: flex-start;
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;
flex-direction: column;
gap: 0.15rem;
}
.card-title-line {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.card-badge-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.15rem;
}
.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;
}
.shared-badge {
background: #ede9fe;
color: #5b21b6;
}
.shared-col {
flex: 0 0 450px;
}
.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;
}
.modal-actions button.primary {
background: #6366f1;
color: white;
}
.step-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.step-tab {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: #9ca3af;
transition: all 0.15s;
}
.step-tab.active {
color: #6366f1;
background: #eef2ff;
font-weight: 600;
}
.step-tab:hover:not(.active) {
background: #f3f4f6;
}
/* ── 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;
}
/* ── Expired todo ── */
.todo-admin-card.todo-expired {
opacity: 0.6;
cursor: default;
background: #f3f4f6;
border-left-color: #9ca3af;
}
.todo-admin-card.todo-expired:hover {
transform: none;
box-shadow: none;
}
.todo-admin-card.todo-expired .todo-admin-name {
text-decoration: line-through;
color: #6b7280;
}
.todo-expired-badge {
font-size: 0.65rem;
font-weight: 700;
color: #dc2626;
background: #fee2e2;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
white-space: nowrap;
}
/* ── 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;
}
/* Mobile: declared last so it wins over the base .kanban/.column rules
(same specificity, source order decides). */
@media (max-width: 639px) {
.template-list {
display: none;
}
.kanban {
flex-direction: column;
overflow-x: visible;
}
.column {
flex: none;
width: 100%;
min-height: auto;
}
.column-placeholder {
display: none;
}
}
</style>