bonuses: templates full-width list, drag-create modal, auto-evaluate, outstanding/completed states

This commit is contained in:
JCEEE
2026-07-30 15:27:03 +01:00
parent c55c090260
commit 8834281b4d
8 changed files with 524 additions and 676 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ export interface BonusProgress {
memberColor: string memberColor: string
current: number current: number
criteriaValue: number criteriaValue: number
reward: { id: string; claimed: boolean } | null reward: { id: string; status: string } | null
state: BonusState state: BonusState
achieved: boolean achieved: boolean
} }
@@ -95,24 +95,20 @@ export const actions = {
if (!event.locals.session) throw redirect(303, '/login'); if (!event.locals.session) throw redirect(303, '/login');
const famId = event.locals.session.famId; const famId = event.locals.session.famId;
const fd = await event.request.formData(); const fd = await event.request.formData();
const templateId = fd.get('templateId') as string;
try {
const templates = await hono.admin.bonusConfigs(event, famId);
const tmpl = (Array.isArray(templates) ? templates : []).find((c: any) => c.id === templateId);
if (!tmpl) return fail(400, { error: 'Template not found' });
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
name: tmpl.name, name: fd.get('name'),
description: tmpl.description || '', description: fd.get('description') || '',
target: tmpl.target, target: fd.get('target'),
type: tmpl.type, type: fd.get('type'),
occurrence: tmpl.occurrence, occurrence: fd.get('occurrence'),
rewardType: tmpl.rewardType, rewardType: fd.get('rewardType'),
rewardValue: tmpl.rewardValue, rewardValue: fd.get('rewardValue'),
criteriaValue: tmpl.criteriaValue || 0, criteriaValue: parseInt(fd.get('criteriaValue') as string, 10) || 0,
period: tmpl.period || '', period: fd.get('period') || '',
memberId: tmpl.memberId || '', memberId: fd.get('memberId') || '',
phase: 'ready', phase: 'active',
}; };
try {
const record = await hono.admin.create(event, 'bonus-configs', famId, data); const record = await hono.admin.create(event, 'bonus-configs', famId, data);
return { record }; return { record };
} catch (e) { } catch (e) {
@@ -17,7 +17,7 @@ import type {
let showCreateModal = $state(false); let showCreateModal = $state(false);
let showEditModal = $state(false); let showEditModal = $state(false);
let editingConfig = $state<BonusConfig | null>(null); let editingConfig = $state<BonusConfig | null>(null);
let editFromTarget = $state<'member' | 'competition' | 'collaboration' | ''>(''); let creatingFromTemplate = $state<BonusConfig | null>(null);
let createVals = $state({ let createVals = $state({
name: '', name: '',
@@ -53,20 +53,28 @@ import type {
let showPeriod = $derived(editVals.occurrence !== 'once'); let showPeriod = $derived(editVals.occurrence !== 'once');
let showCriteria = $derived(editVals.type !== 'manual'); let showCriteria = $derived(editVals.type !== 'manual');
let templateConfigs = $derived(configs.filter((c) => c.phase === 'template' || c.phase === 'active') as BonusConfig[]); let templateConfigs = $derived(configs.filter((c) => c.phase === 'template') as BonusConfig[]);
let readyConfigs = $derived(configs.filter((c) => c.phase === 'ready') as BonusConfig[]);
let activeConfigs = $derived(configs.filter((c) => c.phase === 'active') as BonusConfig[]); let activeConfigs = $derived(configs.filter((c) => c.phase === 'active') as BonusConfig[]);
let completedConfigs = $derived(configs.filter((c) => c.phase === 'completed') as BonusConfig[]);
let completedRewards = $derived(allRewards.filter((r) => r.status === 'claimed') as Reward[]);
let unclaimedRewards = $derived(
allRewards.filter((r) => r.status === 'unclaimed') as Reward[]
);
let progressByConfigId = $derived.by(() => { let progressByConfigId = $derived.by(() => {
const map = new Map<string, BonusConfigWithProgress>(); const map = new Map<string, BonusConfigWithProgress>();
for (const p of progressData) map.set(p.config.id, p); for (const p of progressData) map.set(p.config.id, p);
return map; return map;
}); });
$effect(() => {
const len = famStore.completions.length;
if (len > 0 && famStore.initialized) {
const famId = page.params.fam;
const username = page.params.username;
fetch(`/${famId}/${username}/bonuses?/evaluate`, { method: 'POST' })
.then(() => fetch(`/${famId}/${username}/bonuses/progress.json`))
.then((r) => r.ok && r.json())
.then((d) => { if (d) progressData = d; })
.catch(() => {});
}
});
function memberName(memberId: string): string { function memberName(memberId: string): string {
return ( return (
famStore.memberMap().get(memberId)?.name || famStore.memberMap().get(memberId)?.name ||
@@ -75,15 +83,6 @@ import type {
); );
} }
function configName(bonusConfigId: string | undefined): string {
if (!bonusConfigId) return 'Manual';
return (
famStore.bonusConfigMap().get(bonusConfigId)?.name ||
configs.find((c) => c.id === bonusConfigId)?.name ||
'?'
);
}
function badgeClass(cfg: BonusConfig): string { function badgeClass(cfg: BonusConfig): string {
const classes: string[] = []; const classes: string[] = [];
if (cfg.target === 'individual') classes.push('indiv'); if (cfg.target === 'individual') classes.push('indiv');
@@ -108,7 +107,7 @@ import type {
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
} }
function openDropEdit( function openCreateFromTemplate(
raw: string, raw: string,
targetType: string, targetType: string,
source: 'member' | 'competition' | 'collaboration' source: 'member' | 'competition' | 'collaboration'
@@ -117,7 +116,7 @@ import type {
if (target === targetType) return; if (target === targetType) return;
const cfg = configs.find((c) => c.id === id); const cfg = configs.find((c) => c.id === id);
if (!cfg) return; if (!cfg) return;
editingConfig = cfg; creatingFromTemplate = cfg;
editFromTarget = source; editFromTarget = source;
editVals = { editVals = {
name: cfg.name, name: cfg.name,
@@ -137,37 +136,19 @@ import type {
async function handleDropToMember(e: DragEvent) { async function handleDropToMember(e: DragEvent) {
e.preventDefault(); e.preventDefault();
const raw = e.dataTransfer?.getData('text/plain'); const raw = e.dataTransfer?.getData('text/plain');
if (raw) openDropEdit(raw, 'individual', 'member'); if (raw) openCreateFromTemplate(raw, 'individual', 'member');
} }
async function handleDropToCompetition(e: DragEvent) { async function handleDropToCompetition(e: DragEvent) {
e.preventDefault(); e.preventDefault();
const raw = e.dataTransfer?.getData('text/plain'); const raw = e.dataTransfer?.getData('text/plain');
if (raw) openDropEdit(raw, 'competitive', 'competition'); if (raw) openCreateFromTemplate(raw, 'competitive', 'competition');
} }
async function handleDropToCollaboration(e: DragEvent) { async function handleDropToCollaboration(e: DragEvent) {
e.preventDefault(); e.preventDefault();
const raw = e.dataTransfer?.getData('text/plain'); const raw = e.dataTransfer?.getData('text/plain');
if (raw) openDropEdit(raw, 'collaborative', 'collaboration'); if (raw) openCreateFromTemplate(raw, 'collaborative', 'collaboration');
}
function openEdit(cfg: BonusConfig) {
editingConfig = cfg;
editFromTarget = '';
editVals = {
name: cfg.name,
description: cfg.description || '',
target: cfg.target,
memberId: cfg.memberId || '',
type: cfg.type,
occurrence: cfg.occurrence,
rewardType: cfg.rewardType,
rewardValue: cfg.rewardValue,
criteriaValue: cfg.criteriaValue || 10,
period: cfg.period || 'weekly'
};
showEditModal = true;
} }
function handleCreate() { function handleCreate() {
@@ -176,9 +157,6 @@ import type {
const rec = result.data?.record; const rec = result.data?.record;
if (rec) { if (rec) {
configs = [rec as BonusConfig, ...configs]; configs = [rec as BonusConfig, ...configs];
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('bonus_configs', rec, 'create');
}
} }
showCreateModal = false; showCreateModal = false;
} }
@@ -187,14 +165,9 @@ import type {
function handleUpdate() { function handleUpdate() {
return async ({ result }: any) => { return async ({ result }: any) => {
if (result.type === 'success' && editingConfig) { if (result.type === 'success') {
const updated = { ...editingConfig, ...editVals, rewardValue: editVals.rewardValue };
const idx = configs.findIndex((c) => c.id === editingConfig!.id);
if (idx !== -1) configs[idx] = updated as BonusConfig;
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('bonus_configs', updated, 'update');
}
showEditModal = false; showEditModal = false;
creatingFromTemplate = null;
editingConfig = null; editingConfig = null;
} }
}; };
@@ -207,17 +180,26 @@ import type {
fire(); fire();
} }
const showPeriodForCreate = $derived(createVals.occurrence !== 'once'); const showPeriodForCreate = $derived(createVals.occurrence !== 'once');
const showCriteriaForCreate = $derived(createVals.type !== 'manual'); const showCriteriaForCreate = $derived(createVals.type !== 'manual');
async function evaluateAll() { let outstandingConfigs = $derived(activeConfigs.filter((c) => isOutstanding(c)));
const fd = new FormData(); let doneConfigs = $derived(activeConfigs.filter((c) => isCompleted(c) && !isOutstanding(c)));
fd.set('configId', '');
const res = await fetch(`/${page.params.fam}/${page.params.username}/bonuses?/evaluate`, { method: 'POST', body: fd }); function isCompleted(cfg: BonusConfig): boolean {
const data = await res.json(); const prog = progressByConfigId.get(cfg.id);
if (!data?.error) fire(); if (cfg.occurrence === 'once') {
const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id);
return rewards.length > 0;
}
if (!prog) return false;
return prog.progress.every((p) => p.achieved);
}
function isOutstanding(cfg: BonusConfig): boolean {
if (!isCompleted(cfg)) return false;
const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id);
return rewards.some((r) => r.status === 'unclaimed');
} }
async function fire() { async function fire() {
@@ -230,56 +212,12 @@ import type {
<CardGrid> <CardGrid>
<Card cols={3}> <Card cols={3}>
<div class="action-bar"> <div class="template-section">
<Button onclick={evaluateAll} size="sm">Evaluate All</Button>
</div>
<div class="kanban">
<!-- Templates Column -->
<div class="column templates-col">
<h3>Templates</h3> <h3>Templates</h3>
<div class="template-list">
<!-- Ready section (above CTA) -->
{#if readyConfigs.length}
{#each readyConfigs as cfg}
<div class="card ready">
<div class="card-head">
<strong>{cfg.name}</strong>
<form method="POST" action="?/deleteConfig" use:enhance>
<input type="hidden" name="id" value={cfg.id} />
<button type="submit" class="del-btn" title="Remove">×</button>
</form>
</div>
<div class="badges">
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
<span class="badge">{cfg.type}</span>
</div>
<div class="reward-preview">{formatReward(cfg)}</div>
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
<form method="POST" action="?/assignConfig" use:enhance class="assign-form">
<input type="hidden" name="id" value={cfg.id} />
<select name="target" class="asm-select">
<option value="individual">Individual</option>
<option value="competitive">Competitive</option>
<option value="collaborative">Collaborative</option>
</select>
<select name="memberId" class="asm-select">
<option value="">All</option>
{#each members as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
<button type="submit" class="assign-btn">Assign</button>
</form>
</div>
{/each}
{/if}
<Button onclick={() => (showCreateModal = true)}>+ New Bonus</Button>
{#each templateConfigs as cfg} {#each templateConfigs as cfg}
<div <div
class="card" class="template-card"
draggable="true" draggable="true"
ondragstart={(e) => handleDragStart(e, cfg)} ondragstart={(e) => handleDragStart(e, cfg)}
> >
@@ -297,16 +235,23 @@ import type {
{#if cfg.period}<span class="badge period">{cfg.period}</span>{/if} {#if cfg.period}<span class="badge period">{cfg.period}</span>{/if}
</div> </div>
<div class="reward-preview">{formatReward(cfg)}</div> <div class="reward-preview">{formatReward(cfg)}</div>
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
</div> </div>
{/each} {/each}
{#if templateConfigs.length === 0}
<p class="empty">No templates yet. Create one below.</p>
{/if}
</div>
<Button onclick={() => (showCreateModal = true)}>+ New Template</Button>
</div> </div>
<div class="kanban">
<!-- Member Column --> <!-- Member Column -->
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}> <div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}>
<h3>Member</h3> <h3>Member</h3>
{#each activeConfigs.filter((c) => c.target === 'individual') as cfg} {#each activeConfigs.filter((c) => c.target === 'individual') as cfg}
<div class="col-card"> {@const done = isCompleted(cfg)}
{@const outstanding = isOutstanding(cfg)}
<div class="col-card" class:done class:outstanding>
<div class="col-card-head"> <div class="col-card-head">
<strong>{cfg.name}</strong> <strong>{cfg.name}</strong>
<div class="badges"> <div class="badges">
@@ -321,6 +266,7 @@ import type {
{#if p} {#if p}
<div class="member-progress"> <div class="member-progress">
<span class="mname" style="color:{p.memberColor}">{p.memberName}</span> <span class="mname" style="color:{p.memberColor}">{p.memberName}</span>
{#if p.criteriaValue > 0}
<div class="bar-wrap"> <div class="bar-wrap">
<div <div
class="bar-fill" class="bar-fill"
@@ -328,13 +274,18 @@ import type {
class:met={p.achieved} class:met={p.achieved}
></div> ></div>
</div> </div>
<span class="stat {p.state}">{p.state}</span> {/if}
<span class="stat {p.state}">
{outstanding ? 'Outstanding' : p.state}
</span>
</div> </div>
{/if} {/if}
{/if} {/if}
{/each} {/each}
<div class="col-card-foot"> <div class="col-card-foot">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button> {#if !done}
<Button onclick={() => { editingConfig = cfg; editFromTarget = ''; editVals = { name: cfg.name, description: cfg.description || '', target: cfg.target, memberId: cfg.memberId || '', type: cfg.type, occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, criteriaValue: cfg.criteriaValue || 10, period: cfg.period || 'weekly' }; showEditModal = true; }} variant="secondary" size="sm">Edit</Button>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -347,8 +298,10 @@ import type {
<div class="column comp-col" ondragover={handleDragOver} ondrop={handleDropToCompetition}> <div class="column comp-col" ondragover={handleDragOver} ondrop={handleDropToCompetition}>
<h3>Competition</h3> <h3>Competition</h3>
{#each activeConfigs.filter((c) => c.target === 'competitive') as cfg} {#each activeConfigs.filter((c) => c.target === 'competitive') as cfg}
{@const done = isCompleted(cfg)}
{@const outstanding = isOutstanding(cfg)}
{@const prog = progressByConfigId.get(cfg.id)} {@const prog = progressByConfigId.get(cfg.id)}
<div class="col-card"> <div class="col-card" class:done class:outstanding>
<div class="col-card-head"> <div class="col-card-head">
<strong>{cfg.name}</strong> <strong>{cfg.name}</strong>
<div class="badges"> <div class="badges">
@@ -363,6 +316,7 @@ import type {
<div class="lb-row" class:winner={i === 0 && p.current > 0}> <div class="lb-row" class:winner={i === 0 && p.current > 0}>
<span class="lb-rank">#{i + 1}</span> <span class="lb-rank">#{i + 1}</span>
<span class="mname" style="color:{p.memberColor}">{p.memberName}</span> <span class="mname" style="color:{p.memberColor}">{p.memberName}</span>
{#if p.criteriaValue > 0}
<div class="bar-wrap sm"> <div class="bar-wrap sm">
<div <div
class="bar-fill" class="bar-fill"
@@ -370,13 +324,18 @@ import type {
class:met={p.achieved} class:met={p.achieved}
></div> ></div>
</div> </div>
<span class="stat {p.state}">{p.state}</span> {/if}
<span class="stat {p.state}">
{outstanding ? 'Outstanding' : p.state}
</span>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
<div class="col-card-foot"> <div class="col-card-foot">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button> {#if !done}
<Button onclick={() => { editingConfig = cfg; editFromTarget = ''; editVals = { name: cfg.name, description: cfg.description || '', target: cfg.target, memberId: cfg.memberId || '', type: cfg.type, occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, criteriaValue: cfg.criteriaValue || 10, period: cfg.period || 'weekly' }; showEditModal = true; }} variant="secondary" size="sm">Edit</Button>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -389,8 +348,10 @@ import type {
<div class="column collab-col" ondragover={handleDragOver} ondrop={handleDropToCollaboration}> <div class="column collab-col" ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
<h3>Collaboration</h3> <h3>Collaboration</h3>
{#each activeConfigs.filter((c) => c.target === 'collaborative') as cfg} {#each activeConfigs.filter((c) => c.target === 'collaborative') as cfg}
{@const done = isCompleted(cfg)}
{@const outstanding = isOutstanding(cfg)}
{@const prog = progressByConfigId.get(cfg.id)} {@const prog = progressByConfigId.get(cfg.id)}
<div class="col-card"> <div class="col-card" class:done class:outstanding>
<div class="col-card-head"> <div class="col-card-head">
<strong>{cfg.name}</strong> <strong>{cfg.name}</strong>
<div class="badges"> <div class="badges">
@@ -404,6 +365,7 @@ import type {
{#if team} {#if team}
<div class="team-progress"> <div class="team-progress">
<span class="mname" style="color:{team.memberColor}">{team.memberName}</span> <span class="mname" style="color:{team.memberColor}">{team.memberName}</span>
{#if team.criteriaValue > 0}
<div class="bar-wrap"> <div class="bar-wrap">
<div <div
class="bar-fill" class="bar-fill"
@@ -414,16 +376,20 @@ import type {
class:met={team.achieved} class:met={team.achieved}
></div> ></div>
</div> </div>
<span class="stat" {/if}
>{team.current}/{team.criteriaValue} <span class="stat">
{cfg.type === 'threshold' ? 'pts' : 'chores'}</span {team.current}/{team.criteriaValue}
> {cfg.type === 'threshold' ? 'pts' : 'chores'}</span>
<span class="stat {team.state}">{team.state}</span> <span class="stat {team.state}">
{outstanding ? 'Outstanding' : team.state}
</span>
</div> </div>
{/if} {/if}
{/if} {/if}
<div class="col-card-foot"> <div class="col-card-foot">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button> {#if !done}
<Button onclick={() => { editingConfig = cfg; editFromTarget = ''; editVals = { name: cfg.name, description: cfg.description || '', target: cfg.target, memberId: cfg.memberId || '', type: cfg.type, occurrence: cfg.occurrence, rewardType: cfg.rewardType, rewardValue: cfg.rewardValue, criteriaValue: cfg.criteriaValue || 10, period: cfg.period || 'weekly' }; showEditModal = true; }} variant="secondary" size="sm">Edit</Button>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -433,33 +399,41 @@ import type {
</div> </div>
</div> </div>
<!-- Completed Section --> <!-- Outstanding section (completed configs with unclaimed rewards) -->
{#if completedConfigs.length} {#if outstandingConfigs.length > 0}
<div class="outstanding-section">
<h3>Outstanding — Issue to complete</h3>
<div class="outstanding-grid">
{#each outstandingConfigs as cfg}
{@const rewards = allRewards.filter((r) => r.bonusConfigId === cfg.id && r.status === 'unclaimed')}
<div class="outstanding-card">
<div class="card-head">
<strong>{cfg.name}</strong>
</div>
<div class="reward-preview">{formatReward(cfg)}</div>
{#each rewards as r}
<div class="claim-row">
<span>{memberName(r.memberId)}</span>
<button onclick={() => claimReward(r.id)} class="claim-btn">Issue</button>
</div>
{/each}
</div>
{/each}
</div>
</div>
{/if}
<!-- Completed section (all rewards claimed) -->
{#if doneConfigs.length > 0}
<div class="completed-section"> <div class="completed-section">
<h3>Completed</h3> <h3>Completed</h3>
<div class="completed-grid"> <div class="completed-grid">
{#each completedConfigs as cfg} {#each doneConfigs as cfg}
<div class="completed-card"> <div class="completed-card">
<div class="card-head"> <div class="card-head">
<strong>{cfg.name}</strong> <strong>{cfg.name}</strong>
<div class="badges">
<span class="badge {badgeClass(cfg)}">{cfg.target}</span>
<span class="badge">{cfg.type}</span>
</div>
</div> </div>
<div class="reward-preview">{formatReward(cfg)}</div> <div class="reward-preview">{formatReward(cfg)}</div>
<div class="card-actions">
<Button onclick={() => openEdit(cfg)} variant="secondary" size="sm">Edit</Button>
<form method="POST" action="?/completeConfig" use:enhance>
<input type="hidden" name="id" value={cfg.id} />
<input type="hidden" name="phase" value="active" />
<button type="submit" class="reactivate-btn">Reactivate</button>
</form>
<form method="POST" action="?/destroyConfig" use:enhance>
<input type="hidden" name="id" value={cfg.id} />
<button type="submit" class="del-btn" title="Permanently delete">×</button>
</form>
</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -470,13 +444,12 @@ import type {
{#if showCreateModal} {#if showCreateModal}
<div class="overlay" onclick={() => (showCreateModal = false)} role="presentation"> <div class="overlay" onclick={() => (showCreateModal = false)} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>New Bonus</h3> <h3>New Bonus Template</h3>
<form method="POST" action="?/createConfig" use:enhance={handleCreate}> <form method="POST" action="?/createConfig" use:enhance={handleCreate}>
<input type="hidden" name="phase" value="template" /> <input type="hidden" name="phase" value="template" />
<label>Name <input name="name" bind:value={createVals.name} required /></label> <label>Name <input name="name" bind:value={createVals.name} required /></label>
<label>Description <input name="description" bind:value={createVals.description} /></label> <label>Description <input name="description" bind:value={createVals.description} /></label>
<label <label>Target
>Target
<select name="target" bind:value={createVals.target}> <select name="target" bind:value={createVals.target}>
<option value="individual">Individual</option> <option value="individual">Individual</option>
<option value="competitive">Competitive</option> <option value="competitive">Competitive</option>
@@ -484,8 +457,7 @@ import type {
</select> </select>
</label> </label>
{#if createVals.target === 'individual'} {#if createVals.target === 'individual'}
<label <label>Member
>Member
<select name="memberId" bind:value={createVals.memberId}> <select name="memberId" bind:value={createVals.memberId}>
<option value="">All (everyone)</option> <option value="">All (everyone)</option>
{#each members as m} {#each members as m}
@@ -494,8 +466,7 @@ import type {
</select> </select>
</label> </label>
{/if} {/if}
<label <label>Type
>Type
<select name="type" bind:value={createVals.type}> <select name="type" bind:value={createVals.type}>
<option value="threshold">Threshold (points)</option> <option value="threshold">Threshold (points)</option>
<option value="count">Count (completions)</option> <option value="count">Count (completions)</option>
@@ -503,31 +474,19 @@ import type {
</select> </select>
</label> </label>
{#if showCriteriaForCreate} {#if showCriteriaForCreate}
<label <label>Criteria Value
>Criteria Value <input type="number" name="criteriaValue" bind:value={createVals.criteriaValue} required />
<input <span class="hint">{createVals.type === 'threshold' ? 'Points needed' : 'Chore completions needed'}</span>
type="number"
name="criteriaValue"
bind:value={createVals.criteriaValue}
required
/>
<span class="hint"
>{createVals.type === 'threshold'
? 'Points needed'
: 'Chore completions needed'}</span
>
</label> </label>
{/if} {/if}
<label <label>Occurrence
>Occurrence
<select name="occurrence" bind:value={createVals.occurrence}> <select name="occurrence" bind:value={createVals.occurrence}>
<option value="recurring">Recurring</option> <option value="recurring">Recurring</option>
<option value="once">Once</option> <option value="once">Once</option>
</select> </select>
</label> </label>
{#if showPeriodForCreate} {#if showPeriodForCreate}
<label <label>Period
>Period
<select name="period" bind:value={createVals.period}> <select name="period" bind:value={createVals.period}>
<option value="">Anytime</option> <option value="">Anytime</option>
<option value="weekly">Weekly</option> <option value="weekly">Weekly</option>
@@ -535,24 +494,16 @@ import type {
</select> </select>
</label> </label>
{/if} {/if}
<label <label>Reward Type
>Reward Type
<select name="rewardType" bind:value={createVals.rewardType}> <select name="rewardType" bind:value={createVals.rewardType}>
<option value="points">Points</option> <option value="points">Points</option>
<option value="cash">Cash</option> <option value="cash">Cash</option>
<option value="prize">Prize</option> <option value="prize">Prize</option>
</select> </select>
</label> </label>
<label <label>Reward Value
>Reward Value
<input name="rewardValue" bind:value={createVals.rewardValue} required /> <input name="rewardValue" bind:value={createVals.rewardValue} required />
<span class="hint" <span class="hint">{createVals.rewardType === 'cash' ? 'Amount in pence (e.g. 200 = £2)' : createVals.rewardType === 'points' ? 'Points to award' : 'Prize description'}</span>
>{createVals.rewardType === 'cash'
? 'Amount in pence (e.g. 200 = £2)'
: createVals.rewardType === 'points'
? 'Points to award'
: 'Prize description'}</span
>
</label> </label>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" onclick={() => (showCreateModal = false)}>Cancel</button> <button type="button" onclick={() => (showCreateModal = false)}>Cancel</button>
@@ -563,25 +514,19 @@ import type {
</div> </div>
{/if} {/if}
<!-- Edit Modal --> <!-- Edit / Create-from-Template Modal -->
{#if showEditModal && editingConfig} {#if showEditModal && (editingConfig || creatingFromTemplate)}
<div {@const targetCfg = creatingFromTemplate || editingConfig!}
class="overlay" <div class="overlay" onclick={() => { showEditModal = false; creatingFromTemplate = null; editingConfig = null; }} role="presentation">
onclick={() => {
showEditModal = false;
editingConfig = null;
}}
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Edit: {editingConfig.name}</h3> <h3>{creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name}</h3>
<form method="POST" action="?/updateConfig" use:enhance={handleUpdate}> <form method="POST" action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'} use:enhance={handleUpdate}>
{#if editingConfig}
<input type="hidden" name="id" value={editingConfig.id} /> <input type="hidden" name="id" value={editingConfig.id} />
<input type="hidden" name="templateId" value={editingConfig.id} /> {/if}
<label>Name <input name="name" bind:value={editVals.name} required /></label> <label>Name <input name="name" bind:value={editVals.name} required /></label>
<label>Description <input name="description" bind:value={editVals.description} /></label> <label>Description <input name="description" bind:value={editVals.description} /></label>
<label <label>Target
>Target
<select name="target" bind:value={editVals.target}> <select name="target" bind:value={editVals.target}>
<option value="individual">Individual</option> <option value="individual">Individual</option>
<option value="competitive">Competitive</option> <option value="competitive">Competitive</option>
@@ -589,8 +534,7 @@ import type {
</select> </select>
</label> </label>
{#if editVals.target === 'individual'} {#if editVals.target === 'individual'}
<label <label>Member
>Member
<select name="memberId" bind:value={editVals.memberId}> <select name="memberId" bind:value={editVals.memberId}>
<option value="">All (everyone)</option> <option value="">All (everyone)</option>
{#each members as m} {#each members as m}
@@ -599,8 +543,7 @@ import type {
</select> </select>
</label> </label>
{/if} {/if}
<label <label>Type
>Type
<select name="type" bind:value={editVals.type}> <select name="type" bind:value={editVals.type}>
<option value="threshold">Threshold (points)</option> <option value="threshold">Threshold (points)</option>
<option value="count">Count (completions)</option> <option value="count">Count (completions)</option>
@@ -608,29 +551,19 @@ import type {
</select> </select>
</label> </label>
{#if showCriteria} {#if showCriteria}
<label <label>Criteria Value
>Criteria Value <input type="number" name="criteriaValue" bind:value={editVals.criteriaValue} required />
<input <span class="hint">{editVals.type === 'threshold' ? 'Points needed' : 'Chore completions needed'}</span>
type="number"
name="criteriaValue"
bind:value={editVals.criteriaValue}
required
/>
<span class="hint"
>{editVals.type === 'threshold' ? 'Points needed' : 'Chore completions needed'}</span
>
</label> </label>
{/if} {/if}
<label <label>Occurrence
>Occurrence
<select name="occurrence" bind:value={editVals.occurrence}> <select name="occurrence" bind:value={editVals.occurrence}>
<option value="recurring">Recurring</option> <option value="recurring">Recurring</option>
<option value="once">Once</option> <option value="once">Once</option>
</select> </select>
</label> </label>
{#if showPeriod} {#if showPeriod}
<label <label>Period
>Period
<select name="period" bind:value={editVals.period}> <select name="period" bind:value={editVals.period}>
<option value="">Anytime</option> <option value="">Anytime</option>
<option value="weekly">Weekly</option> <option value="weekly">Weekly</option>
@@ -638,43 +571,20 @@ import type {
</select> </select>
</label> </label>
{/if} {/if}
<label <label>Reward Type
>Reward Type
<select name="rewardType" bind:value={editVals.rewardType}> <select name="rewardType" bind:value={editVals.rewardType}>
<option value="points">Points</option> <option value="points">Points</option>
<option value="cash">Cash</option> <option value="cash">Cash</option>
<option value="prize">Prize</option> <option value="prize">Prize</option>
</select> </select>
</label> </label>
<label <label>Reward Value
>Reward Value
<input name="rewardValue" bind:value={editVals.rewardValue} required /> <input name="rewardValue" bind:value={editVals.rewardValue} required />
<span class="hint" <span class="hint">{editVals.rewardType === 'cash' ? 'Amount in pence' : editVals.rewardType === 'points' ? 'Points to award' : 'Prize description'}</span>
>{editVals.rewardType === 'cash'
? 'Amount in pence'
: editVals.rewardType === 'points'
? 'Points to award'
: 'Prize description'}</span
>
</label> </label>
<div class="modal-actions"> <div class="modal-actions">
<button <button type="button" onclick={() => { showEditModal = false; creatingFromTemplate = null; editingConfig = null; }}>Discard</button>
type="button" <button type="submit">{creatingFromTemplate ? 'Confirm' : 'Save'}</button>
onclick={() => {
showEditModal = false;
editingConfig = null;
}}>Cancel</button
>
<button type="submit">Save</button>
{#if editingConfig.phase === 'template'}
<button type="submit" formaction="?/createFromTemplate" class="create-from-btn">Create Instance</button>
{:else if editingConfig.phase === 'ready' || editingConfig.phase === 'active'}
<input type="hidden" name="phase" value="completed" />
<button type="submit" formaction="?/completeConfig" class="complete-btn">Mark Complete</button>
{:else if editingConfig.phase === 'completed'}
<input type="hidden" name="phase" value="active" />
<button type="submit" formaction="?/completeConfig" class="reactivate-btn">Reactivate</button>
{/if}
</div> </div>
</form> </form>
</div> </div>
@@ -684,6 +594,31 @@ import type {
</CardGrid> </CardGrid>
<style> <style>
.template-section {
margin-bottom: 1.5rem;
}
.template-section h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.template-list {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.template-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.5rem 0.75rem;
min-width: 160px;
cursor: grab;
}
.template-card:active {
cursor: grabbing;
opacity: 0.8;
}
.kanban { .kanban {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -705,43 +640,6 @@ import type {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
font-size: 1rem; font-size: 1rem;
} }
.action-bar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.card-actions { display: flex; gap: 0.3rem; margin-top: 0.3rem; }
.add-btn {
width: 100%;
padding: 0.5rem;
background: #6366f1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.empty {
color: #9ca3af;
font-size: 0.85rem;
text-align: center;
padding: 1rem;
border: 1px dashed #d1d5db;
border-radius: 6px;
}
.card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
cursor: grab;
}
.card:active {
cursor: grabbing;
opacity: 0.8;
}
.card.archived {
opacity: 0.5;
}
.card-head { .card-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -772,7 +670,6 @@ import type {
background: #fce7f3; background: #fce7f3;
color: #be185d; color: #be185d;
} }
.badge.period { .badge.period {
background: #d1fae5; background: #d1fae5;
color: #059669; color: #059669;
@@ -786,15 +683,6 @@ import type {
color: #6b7280; color: #6b7280;
font-weight: 600; font-weight: 600;
} }
.edit-btn {
font-size: 0.7rem;
padding: 2px 8px;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
margin-top: 0.3rem;
}
.del-btn { .del-btn {
background: none; background: none;
border: none; border: none;
@@ -807,12 +695,14 @@ import type {
.del-btn:hover { .del-btn:hover {
color: #dc2626; color: #dc2626;
} }
.archived-hdr { .empty {
font-size: 0.8rem;
color: #9ca3af; color: #9ca3af;
margin: 0.75rem 0 0.25rem; font-size: 0.85rem;
text-align: center;
padding: 1rem;
border: 1px dashed #d1d5db;
border-radius: 6px;
} }
.col-card { .col-card {
background: white; background: white;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
@@ -823,6 +713,10 @@ import type {
.col-card.done { .col-card.done {
opacity: 0.75; opacity: 0.75;
} }
.col-card.outstanding {
border-color: #f59e0b;
background: #fffbeb;
}
.col-card-head { .col-card-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -837,7 +731,6 @@ import type {
display: flex; display: flex;
gap: 0.3rem; gap: 0.3rem;
} }
.member-progress { .member-progress {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -888,29 +781,6 @@ import type {
background: #d1fae5; background: #d1fae5;
color: #059669; color: #059669;
} }
.trigger-btn,
.claim-btn,
.eval-btn {
font-size: 0.65rem;
padding: 2px 8px;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.trigger-btn {
background: #f59e0b;
color: white;
}
.claim-btn {
background: #10b981;
color: white;
}
.eval-btn {
background: #6366f1;
color: white;
}
.leaderboard { .leaderboard {
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
@@ -931,20 +801,15 @@ import type {
color: #9ca3af; color: #9ca3af;
min-width: 20px; min-width: 20px;
} }
.done-body { .claim-btn {
font-size: 0.8rem; background: #10b981;
color: #6b7280; color: white;
border: none;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.65rem;
cursor: pointer;
} }
.done-body .reward-val {
font-weight: 600;
color: #059669;
display: block;
}
.ts {
font-size: 0.7rem;
color: #9ca3af;
}
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1011,44 +876,35 @@ import type {
background: #6366f1; background: #6366f1;
color: white; color: white;
} }
.outstanding-section {
/* ── Lifecycle styles ── */ margin-top: 2rem;
.card.ready { border-top: 2px solid #f59e0b;
border-color: #f59e0b; padding-top: 1rem;
background: #fffbeb;
cursor: default;
} }
.assign-form { .outstanding-section h3 {
font-size: 1rem;
margin: 0 0 0.75rem;
color: #b45309;
}
.outstanding-grid {
display: flex; display: flex;
gap: 4px; gap: 0.5rem;
margin-top: 0.4rem;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.asm-select { .outstanding-card {
font-size: 0.65rem; background: #fffbeb;
padding: 2px 4px; border: 1px solid #f59e0b;
border: 1px solid #d1d5db; border-radius: 6px;
border-radius: 4px; padding: 0.5rem 0.75rem;
flex: 1; min-width: 180px;
min-width: 70px;
} }
.assign-btn, .claim-row {
.create-from-btn, display: flex;
.complete-btn, justify-content: space-between;
.reactivate-btn { align-items: center;
font-size: 0.65rem; font-size: 0.8rem;
padding: 2px 8px; margin-top: 0.3rem;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
} }
.assign-btn { background: #f59e0b; color: white; }
.create-from-btn { background: #6366f1; color: white; }
.complete-btn { background: #10b981; color: white; }
.reactivate-btn { background: #8b5cf6; color: white; }
.completed-section { .completed-section {
margin-top: 2rem; margin-top: 2rem;
border-top: 2px solid #e5e7eb; border-top: 2px solid #e5e7eb;
@@ -0,0 +1,12 @@
import { hono } from '$lib/server/hono';
import { json } from '@sveltejs/kit';
export async function GET(event) {
const famId = event.params.fam;
try {
const progress = await hono.admin.bonusConfigProgress(event, famId);
return json(progress);
} catch {
return json([]);
}
}
@@ -134,15 +134,12 @@
} }
function enhanceCreate() { function enhanceCreate() {
return async ({ result, formData }: any) => { return async ({ result }: any) => {
if (result.type === 'success') { if (result.type === 'success') {
showCreateModal = false showCreateModal = false
const rec = result.data?.record const rec = result.data?.record
if (rec) { if (rec) {
templates = [rec as ChoreTemplate, ...templates] templates = [rec as ChoreTemplate, ...templates]
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('chore_templates', rec, 'create')
}
} }
} }
} }
@@ -161,9 +158,6 @@
customName: editCustomName || undefined, customName: editCustomName || undefined,
seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [], seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [],
} as AssignedChore } as AssignedChore
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
}
} }
} }
closeEdit() closeEdit()
@@ -183,19 +177,16 @@
defaultType: editTplType, defaultType: editTplType,
defaultValue: editTplValue, defaultValue: editTplValue,
} as ChoreTemplate } as ChoreTemplate
}
assigned = assigned.map((a: any) => assigned = assigned.map((a: any) =>
a.templateId === tid a.templateId === tid
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue } ? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
: a : a
) as AssignedChore[] ) as AssignedChore[]
if (famStore.fam?.featureFlags?.optimisticUpdates) {
famStore.applyRecord('chore_templates', templates[idx], 'update')
}
closeEditTemplate() closeEditTemplate()
} }
} }
} }
}
</script> </script>
@@ -239,7 +230,7 @@
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"></button> <button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template"></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[]; if (famStore.fam?.featureFlags?.optimisticUpdates) { const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } } }; }}> <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[]; } }; }}>
<input type="hidden" name="id" value={t.id} /> <input type="hidden" name="id" value={t.id} />
<button type="submit" class="del-btn" title="Delete template">×</button> <button type="submit" class="del-btn" title="Delete template">×</button>
</form> </form>
@@ -98,7 +98,7 @@
</td> </td>
<td> <td>
{#if outstanding} {#if outstanding}
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record && famStore.fam?.featureFlags?.optimisticUpdates) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}> <form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { fire(); } }; }}>
<input name="id" type="hidden" value={r.id} /> <input name="id" type="hidden" value={r.id} />
<Button type="submit" size="sm">Claim</Button> <Button type="submit" size="sm">Claim</Button>
</form> </form>
@@ -169,11 +169,6 @@ import { onDestroy } from 'svelte';
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p> <p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
<form method="POST" action="?/deleteSeason" use:enhance={() => { return async ({ result }) => { <form method="POST" action="?/deleteSeason" use:enhance={() => { return async ({ result }) => {
if (result.type === 'success') { if (result.type === 'success') {
const r = result.data;
if (r?.deletedChoreIds) {
r.deletedChoreIds.forEach((id: string) => famStore.applyRecord('assigned_chores', { id }, 'delete'));
}
famStore.applyRecord('seasons', { id: deletingSeason.id }, 'delete');
deletingSeason = null; deletingSeason = null;
} }
}}}> }}}>
+4 -6
View File
@@ -454,7 +454,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
} }
} }
const summaries = members.items.map((m: any) => { const summaries = await Promise.all(members.items.map(async (m: any) => {
const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id); const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id);
const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id); const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id);
@@ -505,11 +505,9 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
dayPoints, dayPoints,
dayCompletions, dayCompletions,
}; };
}); }));
return c.json({ weekStart: ws, daysInWeek, summaries }); return c.json({ weekStart: ws, daysInWeek, summaries });
return c.json({ weekStart: ws, summaries });
} catch (err) { return handleError(c, err); } } catch (err) { return handleError(c, err); }
}); });
@@ -749,7 +747,7 @@ async function evaluateFam(famId: string): Promise<void> {
famId, memberId: m.id, bonusConfigId: cfg.id, famId, memberId: m.id, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0, label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
claimed: cfg.rewardType === "points", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null, claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10), date: now.slice(0, 10),
}); });
@@ -807,7 +805,7 @@ async function evaluateFam(famId: string): Promise<void> {
: `${cfg.name} ${cfg.rewardValue}`; : `${cfg.name} ${cfg.rewardValue}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
await pb.create("rewards", { await pb.create("rewards", {
famId, memberId: m.id, bonusConfigId: cfg.id, famId, memberId: winner.memberId, bonusConfigId: cfg.id,
label, value: Number(cfg.rewardValue) || 0, label, value: Number(cfg.rewardValue) || 0,
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
status: cfg.rewardType === "points" ? "claimed" : "unclaimed", status: cfg.rewardType === "points" ? "claimed" : "unclaimed",