updated rewards assignment and reward progress

This commit is contained in:
JCEEE
2026-08-31 16:44:37 +01:00
parent cda2cbfcab
commit 3bf78d777c
10 changed files with 549 additions and 209 deletions
@@ -5,8 +5,23 @@
import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone';
import { chevronRight } from '$lib/components/icons';
import Clock from '@lucide/svelte/icons/clock';
import Tag from '@lucide/svelte/icons/tag';
import UsersRound from '@lucide/svelte/icons/users-round';
import Target from '@lucide/svelte/icons/target';
import Settings2 from '@lucide/svelte/icons/settings-2';
import CalendarDays from '@lucide/svelte/icons/calendar-days';
import Coins from '@lucide/svelte/icons/coins';
import Gift from '@lucide/svelte/icons/gift';
import PoundSterling from '@lucide/svelte/icons/pound-sterling';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import Save from '@lucide/svelte/icons/save';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Crown from '@lucide/svelte/icons/crown';
import Ban from '@lucide/svelte/icons/ban';
import type {
BonusConfig,
BonusTemplate,
@@ -18,9 +33,24 @@
let { data }: { data: PageData } = $props();
// Today in the family's timezone (used for the default start date of a
// standalone reward — progress must not count points from before it began).
function todayIso(): string {
const famTz = resolveTz((famStore.fam as any)?.timezone || 'auto');
return todayInTz(famTz);
}
// Numbered-circle marker used for the Who / How / What step tabs and field
// labels (SVG string, same style as lib/components/icons.ts).
function stepIcon(n: number): string {
return `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><text x="12" y="16" text-anchor="middle" font-size="12" font-family="inherit" fill="currentColor" stroke="none">${n}</text></svg>`;
}
let showEditModal = $state(false);
let editingConfig = $state<BonusConfig | null>(null);
let creatingFromTemplate = $state<BonusTemplate | null>(null);
// Multi-step rule editor: 1 = who, 2 = how, 3 = what.
let step = $state(1);
// Kanban columns can collapse (accordion) so templates can be dragged onto
// the desired area on tablet/mobile without cramming three columns.
@@ -56,7 +86,10 @@
rewardValue: '100',
criteriaValue: 10,
period: '' as string,
startMode: 'today' as string
startMode: 'today' as string,
completeBy: 'unlimited' as string,
startDate: '' as string,
completeByDate: '' as string
});
let configs = $derived(
@@ -79,8 +112,9 @@
let showPeriod = $derived(editVals.occurrence !== 'once');
let showCriteria = $derived(editVals.type !== 'manual');
let createIsPercent = $derived(createVals.type === 'threshold' && createVals.thresholdType === 'percent');
let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent');
// Standalone (no period) rewards can scope progress by completeBy + dates.
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global));
let allBonusConfigs = $derived(
@@ -157,8 +191,12 @@
rewardValue: tpl.rewardValue,
criteriaValue: tpl.criteriaValue || 10,
period: tpl.occurrence === 'once' ? '' : tpl.period || '',
startMode: 'today'
startMode: 'today',
completeBy: 'unlimited',
startDate: todayIso(),
completeByDate: ''
};
step = 1;
showEditModal = true;
}
@@ -205,8 +243,12 @@
rewardValue: cfg.rewardValue,
criteriaValue: cfg.criteriaValue || 10,
period: cfg.occurrence === 'once' ? '' : cfg.period || '',
startMode: cfg.status === 'disabled' ? 'disabled' : 'today'
startMode: cfg.status === 'disabled' ? 'disabled' : 'today',
completeBy: cfg.completeBy || 'unlimited',
startDate: cfg.startDate || todayIso(),
completeByDate: cfg.completeByDate || ''
};
step = 1;
showEditModal = true;
}
@@ -496,9 +538,32 @@
role="presentation"
>
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>
<h3 class="modal-title">
{creatingFromTemplate ? 'Create from: ' : 'Edit: '}{targetCfg.name}
</h3>
<!-- Step tabs -->
<div class="step-tabs" role="tablist">
<button
type="button"
class="step-tab"
class:active={step === 1}
onclick={() => (step = 1)}
>{@html stepIcon(1)}<span>Who</span></button>
<button
type="button"
class="step-tab"
class:active={step === 2}
onclick={() => (step = 2)}
>{@html stepIcon(2)}<span>How</span></button>
<button
type="button"
class="step-tab"
class:active={step === 3}
onclick={() => (step = 3)}
>{@html stepIcon(3)}<span>What</span></button>
</div>
<form
method="POST"
action={creatingFromTemplate ? '?/createFromTemplate' : '?/updateConfig'}
@@ -510,199 +575,309 @@
{#if creatingFromTemplate}
<input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} />
{/if}
<label>Name <input name="name" bind:value={editVals.name} required /></label>
<label>Description <input name="description" bind:value={editVals.description} /></label
>
<label
>Target
<select name="target" bind:value={editVals.target}>
<option value="individual">Individual</option>
<option value="competitive">Competition</option>
<option value="collaborative">Collaborative</option>
</select>
</label>
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
<label
>Member
<select name="memberId" bind:value={editVals.memberId}>
<option value="">All (everyone)</option>
{#each members as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
{/if}
<label
>Type
<select name="type" bind:value={editVals.type}>
<option value="threshold">Threshold</option>
<option value="count">Count</option>
<option value="manual">Manual</option>
</select>
</label>
{#if editVals.type === 'threshold'}
<label
>Threshold by
<select name="thresholdType" bind:value={editVals.thresholdType}>
<option value="points">Points</option>
<option value="percent">% of chores done</option>
</select>
</label>
{/if}
{#if showCriteria}
{#if editIsPercent}
<label
>Chores completed (%)
<input
type="range"
min="0"
max="100"
name="criteriaValue"
bind:value={editVals.criteriaValue}
/>
<span class="hint">{editVals.criteriaValue}% of chores this week</span>
<!-- STEP 1 — Who (basics) -->
{#if step === 1}
<div class="step-body">
<label>
<span class="field-label">{@html stepIcon(1)} Name</span>
<input name="name" bind:value={editVals.name} required />
</label>
{:else}
<label
>Target Value
<input
type="number"
name="criteriaValue"
bind:value={editVals.criteriaValue}
required
/>
<span class="hint"
>{editVals.type === 'threshold'
? 'Points to reach'
: editVals.type === 'count'
? 'Number of chores'
: 'N/A'}</span
>
<label>
<span class="field-label">{@html stepIcon(1)} Description</span>
<input name="description" bind:value={editVals.description} />
</label>
{/if}
{/if}
<label
>Occurrence
<select name="occurrence" bind:value={editVals.occurrence}>
<option value="recurring">Recurring</option>
<option value="once">Once</option>
</select>
</label>
{#if showPeriod}
<label
>Period
<select name="period" bind:value={editVals.period}>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</label>
{/if}
<label
>Reward Type
<select name="rewardType" bind:value={editVals.rewardType}>
<option value="points">Points</option>
<option value="cash">Cash</option>
<option value="prize">Prize</option>
</select>
</label>
<label
>Reward Value
<input name="rewardValue" bind:value={editVals.rewardValue} required />
<span class="hint"
>{editVals.rewardType === 'cash'
? 'Amount in £'
: editVals.rewardType === 'points'
? 'Points to award'
: 'Prize description'}</span
>
</label>
{#if creatingFromTemplate}
<div class="start-mode">
<label>When to start</label>
<div class="radio-group">
<label>
<span class="field-label"><UsersRound size={14} /> Target (who)</span>
<select name="target" bind:value={editVals.target}>
<option value="individual">Individual</option>
<option value="competitive">Competition</option>
<option value="collaborative">Collaborative</option>
</select>
</label>
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
<label>
<input
type="radio"
name="startMode"
value="disabled"
bind:group={editVals.startMode}
/>
<span>Disabled</span>
<span class="field-label"><UsersRound size={14} /> Member</span>
<select name="memberId" bind:value={editVals.memberId}>
<option value="">All (everyone)</option>
{#each members as m}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
<label>
<input
type="radio"
name="startMode"
value="today"
bind:group={editVals.startMode}
/>
<span>Today</span>
</label>
<label>
<input
type="radio"
name="startMode"
value="nextWeek"
bind:group={editVals.startMode}
/>
<span>Next week</span>
</label>
</div>
{/if}
</div>
{/if}
<!-- STEP 2 — How (rules + progress) -->
{#if step === 2}
<div class="step-body">
<label>
<span class="field-label"><Settings2 size={14} /> Type</span>
<select name="type" bind:value={editVals.type}>
<option value="threshold">Threshold</option>
<option value="count">Count</option>
<option value="manual">Manual</option>
</select>
</label>
{#if editVals.type === 'threshold'}
<label>
<span class="field-label"><Target size={14} /> Threshold by</span>
<select name="thresholdType" bind:value={editVals.thresholdType}>
<option value="points">Points</option>
<option value="percent">% of chores done</option>
</select>
</label>
{/if}
{#if showCriteria}
{#if editIsPercent}
<label>
<span class="field-label"><Target size={14} /> Chores completed (%)</span>
<input
type="range"
min="0"
max="100"
name="criteriaValue"
bind:value={editVals.criteriaValue}
/>
<span class="hint">{editVals.criteriaValue}% of chores this week</span>
</label>
{:else}
<label>
<span class="field-label"><Target size={14} /> Target Value</span>
<input
type="number"
name="criteriaValue"
bind:value={editVals.criteriaValue}
required
/>
<span class="hint"
>{editVals.type === 'threshold'
? 'Points to reach'
: editVals.type === 'count'
? 'Number of chores'
: 'N/A'}</span
>
</label>
{/if}
{/if}
<label>
<span class="field-label"><Settings2 size={14} /> Occurrence</span>
<select name="occurrence" bind:value={editVals.occurrence}>
<option value="recurring">Recurring</option>
<option value="once">Once</option>
</select>
</label>
{#if showPeriod}
<label>
<span class="field-label"><CalendarDays size={14} /> Period</span>
<select name="period" bind:value={editVals.period}>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
<span class="hint"
>Recurring bonuses reset and re-earn each period. Leave as "None" for a
one-off reward measured once.</span
>
</label>
{/if}
{#if showProgressWindow}
<div class="start-mode">
<label
><span class="field-label"><CalendarDays size={14} /> Progress counts</span
></label>
<div class="radio-group">
<label>
<input
type="radio"
name="completeBy"
value="unlimited"
bind:group={editVals.completeBy}
/>
<span>Unlimited (until reached)</span>
</label>
<label>
<input
type="radio"
name="completeBy"
value="custom"
bind:group={editVals.completeBy}
/>
<span>Until a date</span>
</label>
</div>
{#if editVals.completeBy === 'custom'}
<label>
<span class="field-label"><CalendarDays size={14} /> Complete by</span>
<input type="date" name="completeByDate" bind:value={editVals.completeByDate} />
</label>
{/if}
<label>
<span class="field-label"><CalendarDays size={14} /> Progress starts on</span>
<input type="date" name="startDate" bind:value={editVals.startDate} />
<span class="hint"
>Points before this date won't count toward the reward.</span
>
</label>
</div>
{/if}
</div>
{/if}
<!-- STEP 3 — What (reward) -->
{#if step === 3}
<div class="step-body">
<label>
<span class="field-label">
{#if editVals.rewardType === 'cash'}
<PoundSterling size={14} />
{:else if editVals.rewardType === 'points'}
<Coins size={14} />
{:else}
<Gift size={14} />
{/if}
Reward Type
</span>
<select name="rewardType" bind:value={editVals.rewardType}>
<option value="points">Points</option>
<option value="cash">Cash</option>
<option value="prize">Prize</option>
</select>
</label>
<label>
<span class="field-label">
{#if editVals.rewardType === 'cash'}
<PoundSterling size={14} />
{:else if editVals.rewardType === 'points'}
<Coins size={14} />
{:else}
<Gift size={14} />
{/if}
Reward Value
</span>
<input name="rewardValue" bind:value={editVals.rewardValue} required />
<span class="hint"
>{editVals.rewardType === 'cash'
? 'Amount in £'
: editVals.rewardType === 'points'
? 'Points to award'
: 'Prize description'}</span
>
</label>
{#if creatingFromTemplate}
<div class="start-mode">
<label
><span class="field-label"><CalendarDays size={14} /> When to start</span
></label>
<div class="radio-group">
<label>
<input
type="radio"
name="startMode"
value="disabled"
bind:group={editVals.startMode}
/>
<span>Disabled</span>
</label>
<label>
<input
type="radio"
name="startMode"
value="today"
bind:group={editVals.startMode}
onchange={() => (editVals.startDate = todayIso())}
/>
<span>Today</span>
</label>
<label>
<input
type="radio"
name="startMode"
value="nextWeek"
bind:group={editVals.startMode}
onchange={() => (editVals.startDate = addDaysStr(todayIso(), 7))}
/>
<span>Next week</span>
</label>
</div>
</div>
{/if}
</div>
{/if}
<!-- Footer actions -->
<div class="modal-actions">
{#if editingConfig}
<button
type="button"
class="toggle-btn"
onclick={async () => {
if (!editingConfig) return;
const fd = new FormData();
fd.append('id', editingConfig.id);
fd.append('currentStatus', editingConfig.status);
const res = await fetch('?/toggleConfig', { method: 'POST', body: fd });
if (res.ok) showEditModal = false;
}}
>
{editingConfig.status === 'disabled' ? 'Enable' : 'Disable'}
{#if step === 2}
<button type="button" class="nav-btn" onclick={() => (step = 1)}>
<ArrowLeft size={14} /> Back
</button>
{#if editingConfig.status !== 'completed'}
<button type="button" class="next-btn" onclick={() => (step = 3)}>
Next <ArrowRight size={14} />
</button>
{:else if step === 3}
<button type="button" class="nav-btn" onclick={() => (step = 2)}>
<ArrowLeft size={14} /> Back
</button>
{#if editingConfig}
<button
type="button"
class="complete-btn"
class="toggle-btn"
onclick={async () => {
if (!editingConfig) return;
if (!confirm('Mark this bonus as completed?')) return;
const fd = new FormData();
fd.append('id', editingConfig.id);
const res = await fetch('?/completeConfig', { method: 'POST', body: fd });
fd.append('currentStatus', editingConfig.status);
const res = await fetch('?/toggleConfig', { method: 'POST', body: fd });
if (res.ok) showEditModal = false;
}}
>
Mark Complete
<Ban size={14} />
{editingConfig.status === 'disabled' ? 'Enable' : 'Disable'}
</button>
{#if editingConfig.status !== 'completed'}
<button
type="button"
class="complete-btn"
onclick={async () => {
if (!editingConfig) return;
if (!confirm('Mark this bonus as completed?')) return;
const fd = new FormData();
fd.append('id', editingConfig.id);
const res = await fetch('?/completeConfig', { method: 'POST', body: fd });
if (res.ok) showEditModal = false;
}}
>
<Crown size={14} /> Complete
</button>
{/if}
<button
type="button"
class="delete-btn"
onclick={async () => {
if (!editingConfig) return;
if (!confirm('Delete this bonus?')) return;
const fd = new FormData();
fd.append('id', editingConfig.id);
const res = await fetch('?/destroyConfig', { method: 'POST', body: fd });
if (res.ok) {
showEditModal = false;
editingConfig = null;
}
}}
>
<Trash2 size={14} /> Delete
</button>
{/if}
<button
type="button"
class="delete-btn"
onclick={async () => {
if (!editingConfig) return;
if (!confirm('Delete this bonus?')) return;
const fd = new FormData();
fd.append('id', editingConfig.id);
const res = await fetch('?/destroyConfig', { method: 'POST', body: fd });
if (res.ok) {
showEditModal = false;
editingConfig = null;
}
}}
>
Delete
<button type="submit" class="save-btn">
<Save size={14} /> {creatingFromTemplate ? 'Assign' : 'Save'}
</button>
{:else}
<button type="button" class="next-btn" onclick={() => (step = 2)}>
Next <ArrowRight size={14} />
</button>
{/if}
<button type="submit">{creatingFromTemplate ? 'Assign' : 'Save'}</button>
</div>
</form>
</div>
@@ -1066,6 +1241,7 @@
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.modal-actions button {
padding: 0.5rem 1rem;
@@ -1073,12 +1249,17 @@
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.modal-actions button.nav-btn,
.modal-actions button[type='button'] {
background: #e5e7eb;
color: #374151;
}
.modal-actions button[type='submit'] {
.modal-actions button.next-btn,
.modal-actions button[type='submit'].save-btn {
background: #6366f1;
color: white;
}
@@ -1094,6 +1275,52 @@
background: #ef4444;
color: white;
}
/* Step tabs (Who / How / What) */
.step-tabs {
display: flex;
gap: 2px;
background: #f3f4f6;
border-radius: 6px;
padding: 2px;
margin-bottom: 1rem;
}
.step-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.4rem 0.5rem;
border: none;
background: transparent;
border-radius: 5px;
font-size: 0.85rem;
color: #6b7280;
cursor: pointer;
}
.step-tab.active {
background: #fff;
color: #4338ca;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.step-tab :global(svg) {
flex: none;
}
.field-label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-weight: 500;
color: #374151;
}
.field-label :global(svg) {
flex: none;
color: #6366f1;
}
.step-body {
min-height: 120px;
}
.start-mode {
margin-top: 0.75rem;
}
@@ -1113,5 +1340,19 @@
gap: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
flex-wrap: wrap;
}
/* Progress-counts date inputs must shrink rather than blow the label width. */
.start-mode input[type='date'] {
flex-shrink: 1;
min-width: 0;
}
.step-body input[type='date'] {
flex-shrink: 1;
min-width: 0;
}
.radio-group label input[type='date'] {
flex-shrink: 1;
min-width: 0;
}
</style>