tighten views

This commit is contained in:
JCEEE
2026-08-04 15:32:59 +01:00
parent c38ca43f0b
commit 97bd679954
14 changed files with 776 additions and 309 deletions
@@ -4,7 +4,11 @@
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card } from '$lib/components';
import { formatShortDate } from '$lib/format';
import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types';
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();
@@ -61,6 +65,7 @@
);
let seasons = $state(famStore.initialized ? famStore.seasons : data.seasons || []);
let completions = $state((data.completions as Completion[]) || []);
let filteredAssigned = $derived(
seasonFilter === 'all' ? assigned : assigned.filter((a) => a.seasonIds?.includes(seasonFilter))
@@ -176,6 +181,11 @@
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';
@@ -285,28 +295,29 @@
}
</script>
<ViewHeader title="Chore Assignment" />
<ViewHeader title="Chore Assignment" hero />
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each seasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
<CardGrid>
<!-- Templates Section (at top, like bonuses) -->
<Card cols={3}>
<div class="kanban">
<div class="column templates-col">
<h3>Templates</h3>
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
<div class="template-section">
<div class="template-header">
<h3>Templates — Drag to assign</h3>
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each seasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
</div>
<div class="template-list">
{#each templates as t}
<div class="card template" draggable="true" ondragstart={(e) => handleDragStart(e, t.id)}>
<div class="card-body" onclick={() => openEditTemplate(t)} role="button" tabindex="0">
@@ -343,7 +354,14 @@
<p class="empty">No templates yet</p>
{/if}
</div>
</div>
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
</div>
</Card>
<!-- Member Chore Assignment Kanban (3-column grid) -->
<Card cols={3} scrollX>
<div class="kanban">
{#each members as m}
<div
class="column member-col"
@@ -356,103 +374,119 @@
{m.name}
</h3>
<!-- TODOS accordion -->
<button
class="accordion-head accordion-todos"
onclick={() => toggleAccordion(m.id, 'todos')}
>
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
<span class="accordion-arrow" class:open={accordionState[m.id]?.todos ?? true}> ▾ </span>
</button>
{#if accordionState[m.id]?.todos ?? true}
<button class="add-inline" onclick={() => openTodo(m.id)}>+ Add a todo</button>
{#each sortedTodosForMember(m.id) as a}
{@const urgency = todoUrgency(a)}
<div
class="todo-admin-card"
class:todo-blue-bg={urgency === 'blue'}
class:todo-green-bg={urgency === 'green'}
class:todo-orange-bg={urgency === 'orange'}
class:todo-red-bg={urgency === 'red'}
class:todo-black-bg={urgency === 'black'}
onclick={() => openEdit(a)}
role="button"
tabindex="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">🎯 emoji</span>
{:else}
<span class="todo-admin-type">{a.value} {a.type}</span>
{/if}
{#if a.completeBy}
<span class="todo-admin-deadline">due {formatShortDate(a.completeBy)}</span>
{/if}
</div>
</div>
<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
<!-- TODOS accordion wrapper -->
<div class="accordion-wrapper accordion-todos">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
<span class="accordion-chevron">{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
</button>
{#if accordionState[m.id]?.todos ?? true}
<div class="accordion-body">
{#each sortedTodosForMember(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>
{/each}
{/if}
<!-- CHORES accordion -->
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
<span class="accordion-arrow" class:open={accordionState[m.id]?.chores ?? true}> ▾ </span>
</button>
{#if accordionState[m.id]?.chores ?? true}
{#each assignedForMember(m.id) as a}
{@const tName = templateName(a.templateId)}
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
<div class="card-body">
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#if seasonFilter !== 'all' && isGlobalChore(a)}
<span class="badge season">Add to</span>
<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">🎯 emoji</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 {formatShortDate(a.completeBy)}</span>
{/if}
</div>
</div>
{#if completed}
<span class="todo-completed-badge">✅ 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>
<div class="card-value">
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}
</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}
{/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">
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
<span class="accordion-chevron">{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
</button>
{#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">
<div class="card-body">
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#if seasonFilter !== 'all' && isGlobalChore(a)}
<span class="badge season">Add to</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
>
</Card>
</CardGrid>
<!-- Create Template Modal -->
{#if showCreateModal}
@@ -714,26 +748,48 @@
margin: 0.5rem 0;
}
.kanban {
/* ── Template section (top, like bonuses) ── */
.template-section {
margin-bottom: 0;
}
.template-header {
display: flex;
gap: 1rem;
overflow-x: auto;
align-items: flex-start;
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: grid;
gap: 0.5rem;
margin-bottom: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
min-width: 450px;
}
.column {
flex: 1;
min-width: 200px;
max-width: 280px;
border: 2px solid #e5e7eb;
border-top-width: 4px;
border-radius: 8px;
border: 1px solid #e5e7eb;
border-top: 4px solid #6366f1;
border-radius: 12px;
padding: 0.75rem;
background: #fafafa;
background: #fafbff;
min-height: 200px;
}
.column h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.4rem;
@@ -749,38 +805,58 @@
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1rem;
border: 1px dashed #d1d5db;
border-radius: 6px;
padding: 1.5rem;
}
.add-inline {
display: block;
width: 100%;
margin-bottom: 0.5rem;
padding: 0.4rem;
background: transparent;
border: 1px dashed #d1d5db;
border-radius: 6px;
color: #6b7280;
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;
color: #6366f1;
background: #eef2ff;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
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;
@@ -788,13 +864,14 @@
}
.card-body strong {
display: block;
font-size: 0.9rem;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #1f2937;
}
.card-value {
font-size: 0.85rem;
font-size: 0.8rem;
color: #6b7280;
font-weight: 600;
white-space: nowrap;
@@ -824,15 +901,19 @@
color: #9ca3af;
cursor: pointer;
font-size: 1rem;
padding: 0 4px;
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;
@@ -843,18 +924,18 @@
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.season-filter label {
font-size: 0.85rem;
color: #374151;
font-size: 0.8rem;
color: #6b7280;
font-weight: 500;
}
.season-filter select {
padding: 0.35rem 0.6rem;
padding: 0.3rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
font-size: 0.8rem;
background: #fff;
}
.badge.season {
background: #e0f2fe;
@@ -889,7 +970,7 @@
.template {
cursor: grab;
border-left: 3px solid #6366f1;
border-left: 4px solid #8b5cf6;
}
.template:active {
cursor: grabbing;
@@ -897,10 +978,7 @@
}
.assigned {
cursor: pointer;
border-left: 3px solid #10b981;
}
.assigned:hover {
background: #f3f4f6;
border-left: 4px solid #10b981;
}
.overlay {
@@ -962,38 +1040,45 @@
color: white;
}
/* ── Accordion ── */
/* ── Accordion wrappers ── */
.accordion-wrapper {
background: #6366f1;
border-radius: 10px;
margin: 0.75rem 0 0.3rem;
overflow: hidden;
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.25);
}
.accordion-wrapper.accordion-todos {
background: #2e8b57;
box-shadow: 0 2px 6px rgba(46, 139, 87, 0.25);
}
.accordion-head {
width: 100%;
padding: 0.4rem 0.6rem;
margin: 0.3rem 0 0.2rem;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.55rem 0.85rem;
border: none;
border-left: 4px solid rgba(0,0,0,0.2);
background: transparent;
cursor: pointer;
font-size: 0.8rem;
font-size: 0.82rem;
font-weight: 700;
color: #374151;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
transition: filter 0.15s;
}
.accordion-head:hover {
background: #e5e7eb;
filter: brightness(1.1);
}
.accordion-todos {
background: #fffbeb;
border-color: #fcd34d;
.accordion-body {
padding: 0.5rem 0.65rem 0.65rem;
background: rgba(0,0,0,0.08);
}
.accordion-todos:hover {
background: #fef3c7;
}
.accordion-arrow {
font-size: 0.7rem;
transition: transform 0.2s;
}
.accordion-arrow.open {
transform: rotate(180deg);
.accordion-chevron {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0.8;
}
/* ── Todo admin cards ── */
@@ -1001,16 +1086,28 @@
background: white;
border: 1px solid #e5e7eb;
border-left: 4px solid #3b82f6;
border-radius: 6px;
padding: 0.4rem 0.6rem;
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 {
filter: brightness(0.97);
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;
@@ -1060,8 +1157,39 @@
font-weight: 700;
}
.todo-badge {
background: #fef3c7;
color: #b45309;
.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;
}
/* ── 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>