fixed payout locks & time sensitive todos & minor ui updates on chores view
This commit is contained in:
@@ -138,6 +138,8 @@ export interface Reward {
|
||||
value: number;
|
||||
rewardType: BonusRewardType;
|
||||
status: 'unclaimed' | 'requested' | 'claimed';
|
||||
claimable?: 'immediate' | 'payday';
|
||||
settleDate?: string;
|
||||
claimedAt?: string;
|
||||
requestedAt?: string;
|
||||
date: string;
|
||||
|
||||
@@ -120,7 +120,8 @@ export async function load(event) {
|
||||
tallies: chores.tallies || {},
|
||||
payday: chores.payday,
|
||||
paydayTime: chores.paydayTime || '18:00',
|
||||
timezone: chores.timezone || 'auto'
|
||||
timezone: chores.timezone || 'auto',
|
||||
simulateEow: !!chores.simulateEow
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -161,7 +162,8 @@ export const actions = {
|
||||
const famId = event.locals.session.famId;
|
||||
try {
|
||||
const preview = await hono.admin.eowPreview(event, famId);
|
||||
return { preview };
|
||||
await hono.admin.updateSettings(event, famId, { simulateEow: true });
|
||||
return { preview, simulateEow: true };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to preview payday' };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { memberApi } from '$lib/client/api';
|
||||
import { formatDDMMYY, formatShortDate } from '$lib/format';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||
import {
|
||||
@@ -40,9 +41,8 @@
|
||||
let summary = $state(data.summary);
|
||||
let today = $derived(todayInTz(famTz));
|
||||
|
||||
let simulateEow = $state(!!data.settings?.simulateEow);
|
||||
let simulateEow = $state(!!(data.settings?.simulateEow ?? data.simulateEow));
|
||||
let eowPreview = $state<any>(null);
|
||||
|
||||
const responseTags = [
|
||||
'👏 well done',
|
||||
'😊 really pleased',
|
||||
@@ -352,9 +352,17 @@
|
||||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||
);
|
||||
// Money owed to this member across all time (incl. carry-over from past weeks).
|
||||
// Payday-gated rewards are excluded until their settleDate — the wallet shows
|
||||
// them separately as a locked "pays out on payday" line.
|
||||
let owedCash = $derived.by(() =>
|
||||
rewards
|
||||
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status !== 'claimed')
|
||||
.filter(
|
||||
(r) =>
|
||||
r.memberId === memberId &&
|
||||
r.rewardType === 'cash' &&
|
||||
r.status !== 'claimed' &&
|
||||
!paydayLocked(r)
|
||||
)
|
||||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||||
);
|
||||
let weekPointsEarned = $derived.by(() =>
|
||||
@@ -370,6 +378,10 @@
|
||||
(r.status === 'unclaimed' || r.status === 'requested')
|
||||
)
|
||||
);
|
||||
// Payday-gated bonus rewards unlock on their settleDate (stamped server-side).
|
||||
function paydayLocked(r: any) {
|
||||
return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate;
|
||||
}
|
||||
let weekBonusTallies = $derived.by(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const r of weekRewards) {
|
||||
@@ -786,7 +798,12 @@
|
||||
{#each outstanding as r}
|
||||
<div class="payment-row outstanding">
|
||||
<span>{r.label}</span>
|
||||
<span class="value">{rewardLabel(r)}</span>
|
||||
<span class="value">
|
||||
{rewardLabel(r)}
|
||||
{#if paydayLocked(r)}
|
||||
<span class="owe-locked">🔒 {formatShortDate(r.settleDate)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -822,28 +839,10 @@
|
||||
</Card>
|
||||
</CardGrid>
|
||||
<CardGrid>
|
||||
<Card cols={3} title="Debug: Simulate Payday" accent="#f59e0b">
|
||||
<Card cols={3} title="Debug: Preview payday" accent="#f59e0b">
|
||||
<p class="eow-desc">
|
||||
Read-only preview of what the payday rollover will produce. Nothing here is written.
|
||||
</p>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setEow"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
const d = (result as any).data || {};
|
||||
if (d.error) toast = d.error;
|
||||
else simulateEow = !!d.simulateEow;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="on" value={simulateEow ? 'false' : 'true'} />
|
||||
<button type="submit" class="eow-switch" class:on={simulateEow}>
|
||||
{simulateEow ? 'Simulation ON' : 'Simulation OFF'}
|
||||
</button>
|
||||
</form>
|
||||
<p class="eow-note">
|
||||
This toggles the family debug flag only. It does not alter any live data.
|
||||
Read-only preview of what the payday rollover will settle for this week. Nothing here is
|
||||
written.
|
||||
</p>
|
||||
|
||||
<form
|
||||
@@ -853,19 +852,44 @@
|
||||
return async ({ result }) => {
|
||||
const d = (result as any).data || {};
|
||||
if (d.error) toast = d.error;
|
||||
else eowPreview = d.preview;
|
||||
else {
|
||||
eowPreview = d.preview;
|
||||
simulateEow = !!d.simulateEow;
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="secondary">Preview rollover</Button>
|
||||
<Button type="submit" size="sm" variant="secondary">Preview payday</Button>
|
||||
</form>
|
||||
|
||||
{#if simulateEow}
|
||||
<p class="eow-mode-on">
|
||||
👀 Preview mode is ON — child dashboards show a "payday preview" notice.
|
||||
</p>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setEow"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
const d = (result as any).data || {};
|
||||
if (d.error) toast = d.error;
|
||||
else simulateEow = !!d.simulateEow;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="on" value="false" />
|
||||
<Button type="submit" size="sm" variant="ghost">Turn off preview</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if eowPreview}
|
||||
<div class="eow-out">
|
||||
<p class="eow-range">Week {eowPreview.weekStart} → {eowPreview.weekEnd}</p>
|
||||
<p class="eow-range">
|
||||
Week {formatDDMMYY(eowPreview.weekStart)} → {formatDDMMYY(eowPreview.weekEnd)}
|
||||
</p>
|
||||
<p class="eow-reset">
|
||||
On rollover these {eowPreview.completionsThisWeek} completions reset; week starts anew at
|
||||
{eowPreview.nextWeekStart}.
|
||||
On payday these {eowPreview.completionsThisWeek} completions reset; week starts anew at
|
||||
{formatDDMMYY(eowPreview.nextWeekStart)}.
|
||||
</p>
|
||||
|
||||
<div class="eow-summaries">
|
||||
@@ -904,6 +928,12 @@
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else}
|
||||
{#if simulateEow}
|
||||
<div class="preview-notice">
|
||||
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
|
||||
out yet.
|
||||
</div>
|
||||
{/if}
|
||||
{#if owedCash > 0}
|
||||
<div class="payday-banner">
|
||||
🎉 You've earned <b>£{owedCash.toFixed(2)}</b> — go get it from your parent!
|
||||
@@ -1240,6 +1270,10 @@
|
||||
<span class="wr-pending">✓ credited</span>
|
||||
{:else if isReq}
|
||||
<span class="wr-pending">⏳ waiting</span>
|
||||
{:else if paydayLocked(r)}
|
||||
<span class="wr-pending wr-locked"
|
||||
>🔒 pays out {formatShortDate(r.settleDate)}</span
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="wr-cta"
|
||||
@@ -2020,6 +2054,15 @@
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
}
|
||||
.wr-locked {
|
||||
color: #d97706;
|
||||
}
|
||||
.owe-locked {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
}
|
||||
.wr-cta {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
@@ -2038,6 +2081,30 @@
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.eow-mode-on {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.6rem 0;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px dashed #f59e0b;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.preview-notice {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed #f59e0b;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.payday-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2092,25 +2159,6 @@
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.eow-switch {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
cursor: pointer;
|
||||
}
|
||||
.eow-switch.on {
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
}
|
||||
.eow-note {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
}
|
||||
.eow-out {
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
|
||||
@@ -425,24 +425,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if doneConfigs.length > 0}
|
||||
<div class="completed-section">
|
||||
<h3>Completed</h3>
|
||||
<div class="completed-grid">
|
||||
{#each doneConfigs as cfg}
|
||||
<div class="completed-card">
|
||||
<div class="card-head">
|
||||
<strong>{cfg.name}</strong>
|
||||
</div>
|
||||
<div class="reward-preview">
|
||||
{formatReward(cfg.rewardType, cfg.rewardValue)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCreateModal}
|
||||
<div class="overlay" onclick={() => (showCreateModal = false)} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
@@ -1022,25 +1004,4 @@
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.completed-section {
|
||||
margin-top: 2rem;
|
||||
border-top: 2px solid #10b981;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.completed-section h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.completed-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.completed-card {
|
||||
background: #f0fdf4;
|
||||
border: 2px solid #10b981;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||
import { formatShortDate } from '$lib/format';
|
||||
import type { ChoreTemplate, AssignedChore, Member, Season } from '$lib/types';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -143,7 +144,7 @@
|
||||
let accordionState = $state<Record<string, { chores: boolean; todos: boolean }>>({});
|
||||
|
||||
function toggleAccordion(memberId: string, section: 'chores' | 'todos') {
|
||||
const cur = accordionState[memberId] ?? { chores: false, todos: false };
|
||||
const cur = accordionState[memberId] ?? { chores: true, todos: true };
|
||||
accordionState[memberId] = { ...cur, [section]: !cur[section] };
|
||||
}
|
||||
|
||||
@@ -168,16 +169,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Format DDMMYY for todo deadline display
|
||||
function formatDDMMYY(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const mon = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yr = String(d.getFullYear()).slice(2);
|
||||
return `${day}${mon}${yr}`;
|
||||
}
|
||||
|
||||
// 7 days from today as YYYY-MM-DD for the todo modal default
|
||||
function defaultCompleteByDate(): string {
|
||||
const d = new Date();
|
||||
@@ -314,14 +305,8 @@
|
||||
<Card cols={3}>
|
||||
<div class="kanban">
|
||||
<div class="column templates-col">
|
||||
<h3>
|
||||
Templates
|
||||
<button
|
||||
class="add-todo-btn"
|
||||
title="New Chore Template"
|
||||
onclick={() => (showCreateModal = true)}>+</button
|
||||
>
|
||||
</h3>
|
||||
<h3>Templates</h3>
|
||||
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
|
||||
{#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">
|
||||
@@ -366,22 +351,22 @@
|
||||
ondragover={handleDragOver}
|
||||
ondrop={(e) => handleDrop(e, m.id)}
|
||||
>
|
||||
<h3>
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
{m.name}
|
||||
<button class="add-todo-btn" title="Add Todo" onclick={() => openTodo(m.id)}>+</button>
|
||||
</h3>
|
||||
<h3>
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
{m.name}
|
||||
</h3>
|
||||
|
||||
<!-- TODOS accordion -->
|
||||
<button
|
||||
class="accordion-head accordion-todos"
|
||||
onclick={() => toggleAccordion(m.id, 'todos')}
|
||||
>
|
||||
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
|
||||
<span class="accordion-arrow" class:open={accordionState[m.id]?.todos}> ▾ </span>
|
||||
</button>
|
||||
{#if accordionState[m.id]?.todos}
|
||||
{#each sortedTodosForMember(m.id) as a}
|
||||
<!-- TODOS accordion -->
|
||||
<button
|
||||
class="accordion-head accordion-todos"
|
||||
onclick={() => toggleAccordion(m.id, 'todos')}
|
||||
>
|
||||
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
|
||||
<span class="accordion-arrow" class:open={accordionState[m.id]?.todos ?? true}> ▾ </span>
|
||||
</button>
|
||||
{#if accordionState[m.id]?.todos ?? true}
|
||||
<button class="add-inline" onclick={() => openTodo(m.id)}>+ Add a todo</button>
|
||||
{#each sortedTodosForMember(m.id) as a}
|
||||
{@const urgency = todoUrgency(a)}
|
||||
<div
|
||||
class="todo-admin-card"
|
||||
@@ -403,7 +388,7 @@
|
||||
<span class="todo-admin-type">{a.value} {a.type}</span>
|
||||
{/if}
|
||||
{#if a.completeBy}
|
||||
<span class="todo-admin-deadline">due {formatDDMMYY(a.completeBy)}</span>
|
||||
<span class="todo-admin-deadline">due {formatShortDate(a.completeBy)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,18 +406,15 @@
|
||||
}}>×</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
{#if todosForMember(m.id).length === 0}
|
||||
<button class="empty empty-cta" onclick={() => openTodo(m.id)}>+ Add a todo</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- CHORES accordion -->
|
||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
|
||||
<span class="accordion-arrow" class:open={accordionState[m.id]?.chores}> ▾ </span>
|
||||
</button>
|
||||
{#if accordionState[m.id]?.chores}
|
||||
<!-- CHORES accordion -->
|
||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
|
||||
<span class="accordion-arrow" class:open={accordionState[m.id]?.chores ?? true}> ▾ </span>
|
||||
</button>
|
||||
{#if accordionState[m.id]?.chores ?? true}
|
||||
{#each assignedForMember(m.id) as a}
|
||||
{@const tName = templateName(a.templateId)}
|
||||
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
|
||||
@@ -771,13 +753,19 @@
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.empty-cta {
|
||||
.add-inline {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.empty-cta:hover {
|
||||
.add-inline:hover {
|
||||
border-color: #6366f1;
|
||||
color: #6366f1;
|
||||
background: #eef2ff;
|
||||
@@ -1072,28 +1060,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Add Todo button ── */
|
||||
.add-todo-btn {
|
||||
margin-left: auto;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.add-todo-btn:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
.todo-badge {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
|
||||
Reference in New Issue
Block a user