varierty of implementstions based on platform droplet issuance

This commit is contained in:
JCEEE
2026-08-26 10:17:32 +01:00
parent 7ebabc6e62
commit 8fea21704a
16 changed files with 1118 additions and 452 deletions
@@ -3,6 +3,7 @@
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 { formatHumanDate } from '$lib/format';
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
@@ -14,48 +15,53 @@
let { data, form } = $props();
let showCreateModal = $state(false);
let showEditModal = $state(false);
let showEditTemplateModal = $state(false);
let showTodoModal = $state(false);
let editingAssignment = $state<AssignedChore | null>(null);
let editingTemplate = $state<ChoreTemplate | null>(null);
let editMemberId = $state('');
let draggedTemplateId = $state<string | null>(null);
let todoMemberId = $state('');
let createName = $state('');
let createFreq = $state('daily');
let createType = $state('points');
let createValue = $state(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 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' | 'emoji'>('points');
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 editEmoji = $state('🎉');
let editTodoCompleteBy = $state('');
let editTodoStartDate = $state('');
let editTplName = $state('');
let editTplFreq = $state('daily');
let editTplType = $state('points');
let editTplValue = $state(10);
let seasonFilter = $state('all');
let editSeasonIds = $state<string[]>([]);
let templates = $state(
famStore.initialized
? (famStore.templates as ChoreTemplate[])
: (data.templates as ChoreTemplate[]) || []
// 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[]) || []
@@ -122,20 +128,29 @@
async function handleDrop(e: DragEvent, memberId: string) {
e.preventDefault();
const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId;
if (!tid) return;
draggedTemplateId = null;
if (!tid) return;
const t = templates.find((x) => x.id === tid);
if (!t) return;
assignModal = { t, memberId };
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 def = getDefault(tid);
const body: Record<string, unknown> = {
memberId,
templateId: tid,
frequency: def.frequency,
type: def.type,
value: def.value
memberId: assignModal.memberId,
templateId: assignModal.t.id,
frequency: assignFreq,
type: assignType,
value: assignValue
};
if (seasonFilter !== 'all') body.seasonIds = [seasonFilter];
if (assignSeasonIds.length) body.seasonIds = assignSeasonIds;
await fetch(`/api/admin/${s.famId}/assigned-chores`, {
method: 'POST',
headers: {
@@ -145,6 +160,7 @@
},
body: JSON.stringify(body)
});
assignModal = null;
}
// Accordion state per member: which section is open
@@ -160,6 +176,8 @@
todoName = '';
todoType = 'points';
todoValue = 10;
todoEmoji = '🎉';
showTodoEmojiPicker = false;
todoStart = 'now';
todoCompleteBy = 'custom';
todoCustomDate = defaultCompleteByDate();
@@ -209,6 +227,7 @@
editType = a.type;
editValue = a.value;
editCustomName = a.customName || '';
editEmoji = a.emoji || '🎉';
editSeasonIds = a.seasonIds ? [...a.seasonIds] : [];
editTodoCompleteBy = a.completeBy || '';
editTodoStartDate = a.startDate || '';
@@ -220,32 +239,6 @@
editingAssignment = null;
}
function openEditTemplate(t: ChoreTemplate) {
editingTemplate = t;
editTplName = t.name;
editTplFreq = t.defaultFrequency;
editTplType = t.defaultType;
editTplValue = t.defaultValue;
showEditTemplateModal = true;
}
function closeEditTemplate() {
showEditTemplateModal = false;
editingTemplate = null;
}
function enhanceCreate() {
return async ({ result }: any) => {
if (result.type === 'success') {
showCreateModal = false;
const rec = result.data?.record;
if (rec) {
templates = [rec as ChoreTemplate, ...templates];
}
}
};
}
function enhanceUpdateAssigned() {
return async ({ result }: any) => {
if (result.type === 'success' && editingAssignment) {
@@ -256,7 +249,8 @@
};
if (editingAssignment.isTodo) {
updates.type = editType;
updates.value = editValue;
updates.value = editType === 'emoji' ? 0 : editValue;
updates.emoji = editType === 'emoji' ? editEmoji : undefined;
updates.completeBy = editTodoCompleteBy;
updates.startDate = editTodoStartDate;
} else {
@@ -271,30 +265,6 @@
closeEdit();
};
}
function enhanceUpdateTemplate() {
return async ({ result, formData }: any) => {
if (result.type === 'success' && editingTemplate) {
const tid = editingTemplate!.id;
const idx = templates.findIndex((t: any) => t.id === tid);
if (idx !== -1) {
templates[idx] = {
...templates[idx],
name: editTplName,
defaultFrequency: editTplFreq,
defaultType: editTplType,
defaultValue: editTplValue
} as ChoreTemplate;
assigned = assigned.map((a: any) =>
a.templateId === tid
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
: a
) as AssignedChore[];
closeEditTemplate();
}
}
};
}
</script>
<ViewHeader title="Chore Assignment" hero />
@@ -321,8 +291,9 @@
</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">
<div class="card template" draggable="true" ondragstart={(e) => handleDragStart(e, t.id)} style="border-left-color:{t.color}">
<div class="card-body">
<TemplateIcon name={t.icon} size={18} color={t.color} />
<strong>{t.name}</strong>
<span class="badge">{t.defaultFrequency}</span>
<span class="badge type">{t.defaultType}</span>
@@ -330,18 +301,12 @@
<div class="card-value">
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
</div>
<div class="card-actions">
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"
>✎</button
>
</div>
</div>
{/each}
{#if templates.length === 0}
<p class="empty">No templates yet</p>
{/if}
</div>
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
</div>
</Card>
@@ -389,7 +354,9 @@
<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>
<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
@@ -484,101 +451,36 @@
</Card>
</CardGrid>
<!-- Create Template Modal -->
{#if showCreateModal}
<div class="overlay" onclick={() => (showCreateModal = false)} role="presentation">
<!-- Assign from Template Modal -->
{#if assignModal}
<div class="overlay" onclick={() => (assignModal = null)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>New Chore Template</h3>
<form method="POST" action="?/createTemplate" use:enhance={enhanceCreate}>
<label>
Name
<input name="name" bind:value={createName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={createFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={createType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={createValue} required />
</label>
<div class="modal-actions">
<button type="button" onclick={() => (showCreateModal = false)}>Cancel</button>
<button type="submit">Create</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Template Modal -->
{#if showEditTemplateModal && editingTemplate}
<div class="overlay" onclick={closeEditTemplate} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit Template: {editingTemplate.name}</h3>
<p class="hint">Changes will also update all existing assigned chores using this template.</p>
<form id="updateTplForm" method="POST" action="?/updateTemplate" use:enhance={enhanceUpdateTemplate}>
<input name="id" type="hidden" value={editingTemplate.id} />
<label>
Name
<input name="name" bind:value={editTplName} required />
</label>
<label>
Frequency
<select name="defaultFrequency" bind:value={editTplFreq}>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</label>
<label>
Type
<select name="defaultType" bind:value={editTplType}>
<option value="points">Points</option>
<option value="money">Money</option>
</select>
</label>
<label>
Default Value
<input name="defaultValue" type="number" step="any" bind:value={editTplValue} required />
</label>
</form>
<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>
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="modal-actions">
<button type="button" onclick={closeEditTemplate}>Cancel</button>
<form
method="POST"
action="?/deleteTemplate"
use:enhance={() => {
return async ({ result, formData }) => {
if (result.type === 'success') {
const id = formData.get('id');
templates = templates.filter((t) => t.id !== id) as ChoreTemplate[];
closeEditTemplate();
}
};
}}
>
<input type="hidden" name="id" value={editingTemplate.id} />
<button
type="submit"
class="delete-btn"
onclick={(e) => {
if (!confirm('Delete this template? This cannot be undone.')) {
e.preventDefault();
}
}}>Delete</button
>
</form>
<button type="submit" form="updateTplForm">Save</button>
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
<button type="button" onclick={assignChore}>Assign</button>
</div>
</div>
</div>
@@ -608,7 +510,8 @@
Type
<select name="type" bind:value={editType}>
<option value="points">Points</option>
<option value="emoji">Emoji</option>
<option value="money">Cash</option>
<option value="emoji">Celebration</option>
</select>
</label>
{#if editType === 'points'}
@@ -616,6 +519,37 @@
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
@@ -721,7 +655,8 @@
Type
<select name="type" bind:value={todoType}>
<option value="points">Points</option>
<option value="emoji">Emoji</option>
<option value="money">Cash</option>
<option value="emoji">Celebration</option>
</select>
</label>
{#if todoType === 'points'}
@@ -729,6 +664,34 @@
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
@@ -786,20 +749,20 @@
font-weight: 700;
}
.template-list {
display: grid;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@media (max-width: 1100px) {
.template-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.template-list .card.template {
flex: 0 1 auto;
min-width: 150px;
}
@media (max-width: 720px) {
.template-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.template-list .card.template .card-body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
@@ -877,6 +840,7 @@
.card {
background: white;
border: 1px solid #e5e7eb;
border-left: 4px solid #8b5cf6;
border-radius: 8px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.4rem;
@@ -896,9 +860,11 @@
.card-body {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
.card-body strong {
display: block;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
@@ -1192,6 +1158,38 @@
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;