update footer | add shared chores | expire todos if not completed

This commit is contained in:
JCEEE
2026-09-03 11:50:51 +01:00
parent 8cec99715a
commit 68a0851467
14 changed files with 531 additions and 126 deletions
@@ -28,8 +28,10 @@
// 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');
@@ -37,6 +39,9 @@
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');
@@ -123,6 +128,10 @@
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;
}
@@ -169,6 +178,8 @@
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 || '';
@@ -183,8 +194,9 @@
if (!assignModal) return;
const s = page.data.session as any;
if (!s) return;
const isShared = assignTarget === 'shared';
const body: Record<string, unknown> = {
memberId: assignModal.memberId,
memberId: isShared ? '' : assignModal.memberId,
templateId: assignModal.t.id,
customName: assignName,
description: assignDescription,
@@ -192,7 +204,8 @@
icon: assignIcon,
frequency: assignFreq,
type: assignType,
value: assignValue
value: assignValue,
shared: isShared
};
if (assignSeasonIds.length) body.seasonIds = assignSeasonIds;
await fetch(`/api/admin/${s.famId}/assigned-chores`, {
@@ -205,6 +218,7 @@
body: JSON.stringify(body)
});
assignModal = null;
assignStep = 1;
}
// Accordion state per member: which section is open
@@ -276,6 +290,14 @@
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';
@@ -369,10 +391,14 @@
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>
<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}
@@ -433,18 +459,20 @@
<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-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)}
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 ? -1 : 0}
tabindex={completed || expired ? -1 : 0}
>
<div class="todo-admin-body">
<strong class="todo-admin-name">{a.customName || 'Todo'}</strong>
@@ -468,6 +496,8 @@
{#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"
@@ -497,11 +527,11 @@
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>
<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
>
@@ -523,19 +553,23 @@
)}"
>
<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}
<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`}
@@ -563,6 +597,89 @@
</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}
</div>
{/if}
</div>
</div>
{#if members.length < 3}
{#each Array(3 - members.length) as _}
<div class="column column-placeholder"></div>
@@ -598,80 +715,122 @@
</div>
{/snippet}
<!-- Assign from Template Modal -->
<!-- Assign from Template Modal (Who / How / What wizard) -->
{#if assignModal}
<div
class="overlay"
onclick={(e) => {
if (e.target === e.currentTarget) assignModal = null;
if (e.target === e.currentTarget) { assignModal = null; assignStep = 1; }
}}
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}
<!-- 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>
</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>
{: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">
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
<button type="button" onclick={assignChore}>Assign</button>
{#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>
@@ -992,9 +1151,9 @@
}
.template-list .card.template .card-body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
}
@media (max-width: 1023px) {
.template-list {
@@ -1007,12 +1166,14 @@
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
display: flex;
gap: 0.75rem;
min-width: 450px;
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;
@@ -1085,7 +1246,7 @@
padding: 0.5rem 0.75rem;
margin-bottom: 0.4rem;
display: flex;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
position: relative;
transition:
@@ -1100,9 +1261,21 @@
.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;
@@ -1129,6 +1302,13 @@
background: #fce7f3;
color: #be185d;
}
.shared-badge {
background: #ede9fe;
color: #5b21b6;
}
.shared-col {
flex: 0 0 450px;
}
.card-actions {
display: flex;
align-items: center;
@@ -1374,6 +1554,38 @@
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 {
@@ -1587,6 +1799,32 @@
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;