fix some issues - including add ledger page and fix reoccuring completions
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
├── [fam]/+page.svelte ← fam dashboard
|
├── [fam]/+page.svelte ← fam dashboard
|
||||||
├── [fam]/{username}/+page.svelte ← parent=admin overview, child=kanban
|
├── [fam]/{username}/+page.svelte ← parent=admin overview, child=kanban
|
||||||
├── [fam]/{username}/chores/+page.svelte ← parent only
|
├── [fam]/{username}/chores/+page.svelte ← parent only
|
||||||
├── [fam]/{username}/rewards/+page.svelte ← parent only
|
├── [fam]/{username}/ledger/+page.svelte ← parent only (rewards/chores/todos)
|
||||||
├── [fam]/{username}/bonuses/+page.svelte ← parent only
|
├── [fam]/{username}/bonuses/+page.svelte ← parent only
|
||||||
├── [fam]/{username}/settings/+page.svelte ← parent only
|
├── [fam]/{username}/settings/+page.svelte ← parent only
|
||||||
└── [fam]/{username}/preferences/+page.svelte ← both roles
|
└── [fam]/{username}/preferences/+page.svelte ← both roles
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
/{fam} Fam dashboard
|
/{fam} Fam dashboard
|
||||||
/{fam}/{username} Parent → admin overview, Child → kanban
|
/{fam}/{username} Parent → admin overview, Child → kanban
|
||||||
/{fam}/{username}/chores Parent: chore management
|
/{fam}/{username}/chores Parent: chore management
|
||||||
/{fam}/{username}/rewards Parent: reward ledger
|
/{fam}/{username}/ledger Parent: rewards / chores / todos ledger
|
||||||
/{fam}/{username}/bonuses Parent: bonus configs
|
/{fam}/{username}/bonuses Parent: bonus configs
|
||||||
/{fam}/{username}/settings Parent: family settings
|
/{fam}/{username}/settings Parent: family settings
|
||||||
/{fam}/{username}/preferences Both: edit name/color
|
/{fam}/{username}/preferences Both: edit name/color
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { dashboardIcon, choresIcon, rewardsIcon, bonusesIcon, homeIcon, settingsIcon, logoutIcon, prefsIcon } from './icons';
|
import {
|
||||||
|
dashboardIcon,
|
||||||
|
choresIcon,
|
||||||
|
rewardsIcon,
|
||||||
|
bonusesIcon,
|
||||||
|
homeIcon,
|
||||||
|
settingsIcon,
|
||||||
|
logoutIcon,
|
||||||
|
prefsIcon
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
||||||
|
|
||||||
@@ -9,30 +18,41 @@
|
|||||||
let famSlug = $derived(page.params.fam);
|
let famSlug = $derived(page.params.fam);
|
||||||
let memberName = $derived(session?.memberName || page.params.username || '');
|
let memberName = $derived(session?.memberName || page.params.username || '');
|
||||||
|
|
||||||
function toggle() { collapsed = !collapsed; }
|
function toggle() {
|
||||||
|
collapsed = !collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
let showChildItems = $derived(!!memberName);
|
let showChildItems = $derived(!!memberName);
|
||||||
|
|
||||||
let navItems = $derived(isParent && memberName
|
let navItems = $derived(
|
||||||
|
isParent && memberName
|
||||||
? [
|
? [
|
||||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
|
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon },
|
||||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
|
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }
|
||||||
]
|
]
|
||||||
: showChildItems
|
: showChildItems
|
||||||
? [
|
? [
|
||||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
let footerItems = $derived([
|
let footerItems = $derived([
|
||||||
...(memberName ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] : []),
|
...(memberName
|
||||||
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
|
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
|
||||||
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
|
: []),
|
||||||
|
...(isParent && memberName
|
||||||
|
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
href: session ? '/logout' : '/login',
|
||||||
|
label: session ? 'Log out' : 'Log in',
|
||||||
|
icon: logoutIcon
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -68,7 +88,8 @@
|
|||||||
<style>
|
<style>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
background: #1e1b4b;
|
background: #1e1b4b;
|
||||||
@@ -79,10 +100,13 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed { width: 56px; }
|
.sidebar.collapsed {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem; right: 0.5rem;
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #a5b4fc;
|
color: #a5b4fc;
|
||||||
@@ -99,8 +123,15 @@
|
|||||||
border-bottom: 1px solid #3730a3;
|
border-bottom: 1px solid #3730a3;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
}
|
}
|
||||||
.app-icon { font-size: 1.3rem; flex-shrink: 0; }
|
.app-icon {
|
||||||
.app-name { font-weight: 700; font-size: 1.05rem; white-space: nowrap; }
|
font-size: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
@@ -128,7 +159,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.nav-item:hover { background: #3730a3; color: #e0e7ff; }
|
.nav-item:hover {
|
||||||
.nav-item.active { background: #4338ca; color: #fff; font-weight: 600; }
|
background: #3730a3;
|
||||||
.nav-label { overflow: hidden; }
|
color: #e0e7ff;
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
// Format a YYYY-MM-DD (or ISO) string as DDMMYY for user-facing dates.
|
// Two date display views used across the app:
|
||||||
|
// - Data view: `08-08-26` (dashed DD-MM-YY) — compact, tabular-friendly.
|
||||||
|
// - Human view: weekday in a badge (`Thursday`) with month/year added per requirement.
|
||||||
|
|
||||||
|
// Format a YYYY-MM-DD (or ISO) string as a compact dashed data-view date.
|
||||||
export function formatDDMMYY(dateStr: string | undefined): string {
|
export function formatDDMMYY(dateStr: string | undefined): string {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
const mon = String(d.getMonth() + 1).padStart(2, '0');
|
const mon = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
const yr = String(d.getFullYear()).slice(2);
|
const yr = String(d.getFullYear()).slice(2);
|
||||||
return `${day}${mon}${yr}`;
|
return `${day}-${mon}-${yr}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human-friendly date for due dates etc: "5 Aug" (adds year when not the
|
// Human view — the weekday name ("Thursday"). Render this inside a badge.
|
||||||
// current one, e.g. "5 Aug 26"). Better than the compact DDMMYY code.
|
export function formatWeekday(dateStr: string | undefined): string {
|
||||||
export function formatShortDate(dateStr: string | undefined): string {
|
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleDateString('en-GB', { weekday: 'long' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human view — weekday + short date ("Thursday 7 Aug", adds year when not the
|
||||||
|
// current one, e.g. "Thursday 7 Aug 26").
|
||||||
|
export function formatHumanDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr.slice(0, 10) + 'T00:00:00');
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const weekday = d.toLocaleDateString('en-GB', { weekday: 'long' });
|
||||||
const mon = d.toLocaleDateString('en-GB', { month: 'short' });
|
const mon = d.toLocaleDateString('en-GB', { month: 'short' });
|
||||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||||
return `${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
|
return `${weekday} ${d.getDate()} ${mon}${sameYear ? '' : ' ' + String(d.getFullYear()).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { memberApi } from '$lib/client/api';
|
import { memberApi } from '$lib/client/api';
|
||||||
import { formatDDMMYY, formatShortDate } from '$lib/format';
|
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
wallClockToUtc,
|
wallClockToUtc,
|
||||||
resolveTz,
|
resolveTz,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd
|
periodEnd,
|
||||||
|
isCompleteForPeriod
|
||||||
} from '../../../../../timezone.ts';
|
} from '../../../../../timezone.ts';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
@@ -254,7 +255,8 @@
|
|||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
Math.round(
|
Math.round(
|
||||||
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
|
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() -
|
||||||
|
new Date(todayIso + 'T00:00:00').getTime()) /
|
||||||
86400000
|
86400000
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -541,14 +543,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCompleted(assignedChoreId: string, date: string): boolean {
|
function isCompleted(assignedChoreId: string, date: string): boolean {
|
||||||
|
const a = assigned.find((x) => x.id === assignedChoreId);
|
||||||
|
// Weekly chores are "done for the week" — complete if completed any day
|
||||||
|
// in the current week, not just today.
|
||||||
|
if (a?.frequency === 'weekly') {
|
||||||
|
const dates = completions
|
||||||
|
.filter((c) => c.assignedChoreId === assignedChoreId)
|
||||||
|
.map((c) => c.date);
|
||||||
|
return isCompleteForPeriod('weekly', paydayDay, famTz, dates);
|
||||||
|
}
|
||||||
return completions.some(
|
return completions.some(
|
||||||
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
|
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCompletion(assignedChoreId: string) {
|
// Todos are one-off: done once the completion row exists, regardless of date.
|
||||||
|
function isTodoDone(todoId: string): boolean {
|
||||||
|
return completions.some((c) => c.assignedChoreId === todoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCompletion(chore: AssignedChore) {
|
||||||
const match = (c: Completion) =>
|
const match = (c: Completion) =>
|
||||||
c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild;
|
chore.isTodo
|
||||||
|
? c.assignedChoreId === chore.id
|
||||||
|
: chore.frequency === 'weekly'
|
||||||
|
? c.assignedChoreId === chore.id &&
|
||||||
|
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
||||||
|
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
|
||||||
|
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === todayChild;
|
||||||
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
||||||
return optimistic || completions.find(match);
|
return optimistic || completions.find(match);
|
||||||
}
|
}
|
||||||
@@ -557,9 +579,9 @@
|
|||||||
if (togglingIds) return;
|
if (togglingIds) return;
|
||||||
togglingIds = chore.id;
|
togglingIds = chore.id;
|
||||||
|
|
||||||
const wasCompleted = isCompleted(chore.id, todayChild);
|
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild);
|
||||||
if (wasCompleted) {
|
if (wasCompleted) {
|
||||||
const existing = findCompletion(chore.id);
|
const existing = findCompletion(chore);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
famStore.applyRecord('completions', existing, 'delete');
|
famStore.applyRecord('completions', existing, 'delete');
|
||||||
}
|
}
|
||||||
@@ -777,7 +799,10 @@
|
|||||||
(sum: number, r: any) => sum + Number(r.value),
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
0
|
0
|
||||||
)}
|
)}
|
||||||
{@const total = cashClaimable.reduce((sum: number, r: any) => sum + Number(r.value), 0)}
|
{@const total = cashClaimable.reduce(
|
||||||
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
|
0
|
||||||
|
)}
|
||||||
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
||||||
{#if hasClaims}
|
{#if hasClaims}
|
||||||
<div class="member-claims">
|
<div class="member-claims">
|
||||||
@@ -835,7 +860,7 @@
|
|||||||
<span class="value">
|
<span class="value">
|
||||||
{rewardLabel(r)}
|
{rewardLabel(r)}
|
||||||
{#if paydayLocked(r)}
|
{#if paydayLocked(r)}
|
||||||
<span class="owe-locked">🔒 {formatShortDate(r.settleDate)}</span>
|
<span class="owe-locked">🔒 {formatHumanDate(r.settleDate)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -966,8 +991,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#if simulateEow}
|
{#if simulateEow}
|
||||||
<div class="preview-notice">
|
<div class="preview-notice">
|
||||||
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
|
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid out yet.
|
||||||
out yet.
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if owedCash > 0}
|
{#if owedCash > 0}
|
||||||
@@ -1205,10 +1229,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="column col-weekly">
|
<div class="column col-weekly">
|
||||||
<h2>
|
<h2>
|
||||||
📅 Weekly ({weeklyPending.length +
|
📅 Weekly ({weeklyPending.length + memberTodos.filter((t) => !isTodoDone(t.id)).length})
|
||||||
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
|
|
||||||
</h2>
|
</h2>
|
||||||
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
|
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isTodoDone(t.id)).length === 0}
|
||||||
<p class="empty">All done!</p>
|
<p class="empty">All done!</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each weeklyPending as chore}
|
{#each weeklyPending as chore}
|
||||||
@@ -1218,7 +1241,7 @@
|
|||||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
|
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
|
||||||
{@const urgency = todoUrgency(todo)}
|
{@const urgency = todoUrgency(todo)}
|
||||||
{#if todo.type === 'emoji'}
|
{#if todo.type === 'emoji'}
|
||||||
<div
|
<div
|
||||||
@@ -1307,8 +1330,7 @@
|
|||||||
{:else if isReq}
|
{:else if isReq}
|
||||||
<span class="wr-pending">⏳ waiting</span>
|
<span class="wr-pending">⏳ waiting</span>
|
||||||
{:else if paydayLocked(r)}
|
{:else if paydayLocked(r)}
|
||||||
<span class="wr-pending wr-locked"
|
<span class="wr-pending wr-locked">🔒 pays out {formatHumanDate(r.settleDate)}</span
|
||||||
>🔒 pays out {formatShortDate(r.settleDate)}</span
|
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -1369,7 +1391,10 @@
|
|||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
background: white;
|
background: white;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
}
|
}
|
||||||
.member-card:hover {
|
.member-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -1478,7 +1503,9 @@
|
|||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
background: #f0fdf4;
|
background: #f0fdf4;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.trigger-card:hover {
|
.trigger-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
|||||||
@@ -262,7 +262,6 @@
|
|||||||
<p class="empty">No templates yet</p>
|
<p class="empty">No templates yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Button onclick={() => (showCreateModal = true)}>New Template</Button>
|
<Button onclick={() => (showCreateModal = true)}>New Template</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||||
import { formatShortDate } from '$lib/format';
|
import { formatHumanDate } from '$lib/format';
|
||||||
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
||||||
|
|
||||||
// ── Chevron SVG icons ──
|
// ── Chevron SVG icons ──
|
||||||
const CHEVRON_DOWN = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
const CHEVRON_DOWN =
|
||||||
const CHEVRON_UP = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||||
|
const CHEVRON_UP =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -354,7 +356,6 @@
|
|||||||
<p class="empty">No templates yet</p>
|
<p class="empty">No templates yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
|
<button class="add-inline" onclick={() => (showCreateModal = true)}>+ New template</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -378,7 +379,9 @@
|
|||||||
<div class="accordion-wrapper accordion-todos">
|
<div class="accordion-wrapper accordion-todos">
|
||||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
|
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
|
||||||
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
|
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span>
|
||||||
<span class="accordion-chevron">{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
|
<span class="accordion-chevron"
|
||||||
|
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{#if accordionState[m.id]?.todos ?? true}
|
{#if accordionState[m.id]?.todos ?? true}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
@@ -403,10 +406,14 @@
|
|||||||
{#if a.type === 'emoji'}
|
{#if a.type === 'emoji'}
|
||||||
<span class="todo-admin-type">🎯 emoji</span>
|
<span class="todo-admin-type">🎯 emoji</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="todo-admin-type"><span class="badge badge-pts">{a.value} pts</span></span>
|
<span class="todo-admin-type"
|
||||||
|
><span class="badge badge-pts">{a.value} pts</span></span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if a.completeBy}
|
{#if a.completeBy}
|
||||||
<span class="todo-admin-deadline">due {formatShortDate(a.completeBy)}</span>
|
<span class="todo-admin-deadline"
|
||||||
|
>due {formatHumanDate(a.completeBy)}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +436,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}>+ Add a todo</button>
|
<button class="add-inline accordion-add" onclick={() => openTodo(m.id)}
|
||||||
|
>+ Add a todo</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,7 +447,9 @@
|
|||||||
<div class="accordion-wrapper accordion-chores">
|
<div class="accordion-wrapper accordion-chores">
|
||||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||||
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
|
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span>
|
||||||
<span class="accordion-chevron">{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span>
|
<span class="accordion-chevron"
|
||||||
|
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{#if accordionState[m.id]?.chores ?? true}
|
{#if accordionState[m.id]?.chores ?? true}
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
@@ -819,7 +830,9 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.template-section > .add-inline {
|
.template-section > .add-inline {
|
||||||
@@ -828,13 +841,13 @@
|
|||||||
.accordion-add {
|
.accordion-add {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
background: rgba(255,255,255,0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
border-color: rgba(255,255,255,0.4);
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.accordion-add:hover {
|
.accordion-add:hover {
|
||||||
background: rgba(255,255,255,0.35);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
border-color: rgba(255,255,255,0.6);
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.add-inline:hover {
|
.add-inline:hover {
|
||||||
@@ -852,7 +865,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
}
|
}
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -905,7 +921,9 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: color 0.15s, background 0.15s;
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
}
|
}
|
||||||
.edit-btn:hover {
|
.edit-btn:hover {
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
@@ -1056,7 +1074,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.55rem 0.85rem;
|
padding: 0.55rem 0.85rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 4px solid rgba(0,0,0,0.2);
|
border-left: 4px solid rgba(0, 0, 0, 0.2);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -1072,7 +1090,7 @@
|
|||||||
}
|
}
|
||||||
.accordion-body {
|
.accordion-body {
|
||||||
padding: 0.5rem 0.65rem 0.65rem;
|
padding: 0.5rem 0.65rem 0.65rem;
|
||||||
background: rgba(0,0,0,0.08);
|
background: rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
.accordion-chevron {
|
.accordion-chevron {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1093,7 +1111,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
transition: transform 0.15s, box-shadow 0.15s, opacity 0.2s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
opacity 0.2s;
|
||||||
}
|
}
|
||||||
.todo-admin-card:hover {
|
.todo-admin-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -1185,8 +1206,8 @@
|
|||||||
45deg,
|
45deg,
|
||||||
transparent,
|
transparent,
|
||||||
transparent 8px,
|
transparent 8px,
|
||||||
rgba(0,0,0,0.02) 8px,
|
rgba(0, 0, 0, 0.02) 8px,
|
||||||
rgba(0,0,0,0.02) 16px
|
rgba(0, 0, 0, 0.02) 16px
|
||||||
);
|
);
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
+6
-3
@@ -4,11 +4,14 @@ import { hono } from '$lib/server/hono';
|
|||||||
export async function load(event) {
|
export async function load(event) {
|
||||||
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 [rewards, members] = await Promise.all([
|
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
||||||
hono.admin.rewards(event, famId),
|
hono.admin.rewards(event, famId),
|
||||||
hono.admin.list(event, 'members', famId),
|
hono.admin.list(event, 'members', famId),
|
||||||
|
hono.admin.list(event, 'assigned-chores', famId),
|
||||||
|
hono.admin.list(event, 'chore-templates', famId),
|
||||||
|
hono.admin.completions(event, famId)
|
||||||
]);
|
]);
|
||||||
return { rewards, members };
|
return { rewards, members, assigned, templates, completions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@@ -23,5 +26,5 @@ export const actions = {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
|
import { formatDDMMYY } from '$lib/format';
|
||||||
|
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
// Derived from famStore so SSE updates flow through (AGENTS.md rule).
|
||||||
|
let rewards = $derived(
|
||||||
|
famStore.initialized ? famStore.rewards : ((data.rewards || []) as Reward[])
|
||||||
|
);
|
||||||
|
let members = $derived(
|
||||||
|
famStore.initialized ? famStore.members : ((data.members || []) as Member[])
|
||||||
|
);
|
||||||
|
let assigned = $derived(
|
||||||
|
famStore.initialized ? famStore.assigned : ((data.assigned || []) as AssignedChore[])
|
||||||
|
);
|
||||||
|
let completions = $derived(
|
||||||
|
famStore.initialized ? famStore.completions : ((data.completions || []) as Completion[])
|
||||||
|
);
|
||||||
|
let templates = $derived(
|
||||||
|
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
|
||||||
|
);
|
||||||
|
|
||||||
|
let activeTab = $state('rewards');
|
||||||
|
|
||||||
|
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
|
||||||
|
let templateMap = $derived.by(() => new Map(templates.map((t: any) => [t.id, t])));
|
||||||
|
let assignedMap = $derived.by(() => new Map(assigned.map((a: any) => [a.id, a])));
|
||||||
|
|
||||||
|
function memberName(memberId: string): string {
|
||||||
|
return memberMap.get(memberId)?.name || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberColor(memberId: string): string {
|
||||||
|
return memberMap.get(memberId)?.color || '#6366f1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function choreName(a: any): string {
|
||||||
|
return a?.customName || templateMap.get(a?.templateId)?.name || 'Chore';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rewards tab ──
|
||||||
|
function rewardAmount(r: any): string {
|
||||||
|
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||||||
|
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||||
|
return r.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewardStatus(r: any): string {
|
||||||
|
if (r.status === 'unclaimed') return 'Outstanding';
|
||||||
|
if (r.status === 'requested') return 'Requested';
|
||||||
|
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
|
||||||
|
return 'Claimed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutstanding(r: any): boolean {
|
||||||
|
return r.status === 'unclaimed';
|
||||||
|
}
|
||||||
|
function isRequested(r: any): boolean {
|
||||||
|
return r.status === 'requested';
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedRewards = $derived(
|
||||||
|
[...rewards].sort((a: any, b: any) => {
|
||||||
|
const da = a.date || a.id || '';
|
||||||
|
const db = b.date || b.id || '';
|
||||||
|
return da < db ? 1 : da > db ? -1 : 0;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalOutstanding = $derived(
|
||||||
|
rewards
|
||||||
|
.filter((r: any) => r.status !== 'claimed')
|
||||||
|
.filter((r: any) => r.rewardType === 'cash')
|
||||||
|
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Chores / Todos tabs ──
|
||||||
|
let choreCompletions = $derived(
|
||||||
|
completions.filter((c: any) => !assignedMap.get(c.assignedChoreId)?.isTodo)
|
||||||
|
);
|
||||||
|
let todoCompletions = $derived(
|
||||||
|
completions.filter((c: any) => assignedMap.get(c.assignedChoreId)?.isTodo)
|
||||||
|
);
|
||||||
|
|
||||||
|
let sortedChoreCompletions = $derived(
|
||||||
|
[...choreCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||||
|
);
|
||||||
|
let sortedTodoCompletions = $derived(
|
||||||
|
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
let toast = $state('');
|
||||||
|
|
||||||
|
async function fire() {
|
||||||
|
const { default: confetti } = await import('@hiseb/confetti');
|
||||||
|
confetti({
|
||||||
|
count: 80,
|
||||||
|
size: 4,
|
||||||
|
velocity: 500,
|
||||||
|
fade: true,
|
||||||
|
position: { x: window.innerWidth / 2, y: 0 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg: string) {
|
||||||
|
toast = msg;
|
||||||
|
setTimeout(() => (toast = ''), 4000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ViewHeader
|
||||||
|
title="Ledger"
|
||||||
|
subtitle="Rewards, chores and todos earned by members"
|
||||||
|
hero
|
||||||
|
tabs={{
|
||||||
|
items: [
|
||||||
|
{ label: 'Rewards', value: 'rewards' },
|
||||||
|
{ label: 'Chores', value: 'chores' },
|
||||||
|
{ label: 'Todos', value: 'todos' }
|
||||||
|
],
|
||||||
|
active: activeTab,
|
||||||
|
onchange: (v: string) => (activeTab = v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if toast}
|
||||||
|
<div class="toast">{toast}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<CardGrid>
|
||||||
|
{#if activeTab === 'rewards'}
|
||||||
|
<Card cols={3}>
|
||||||
|
{#if rewards.length === 0}
|
||||||
|
<p class="empty">
|
||||||
|
No rewards yet. Points earned will automatically create bonus rewards when thresholds are
|
||||||
|
met.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedRewards as r}
|
||||||
|
{@const outstanding = isOutstanding(r)}
|
||||||
|
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
||||||
|
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
||||||
|
{memberName(r.memberId)}
|
||||||
|
</td>
|
||||||
|
<td>{r.label}</td>
|
||||||
|
<td class="num">{rewardAmount(r)}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="status-badge"
|
||||||
|
class:outstanding
|
||||||
|
class:requested-status={isRequested(r)}
|
||||||
|
class:claimed-status={!outstanding && !isRequested(r)}
|
||||||
|
>
|
||||||
|
{rewardStatus(r)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#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') fire();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input name="id" type="hidden" value={r.id} />
|
||||||
|
<Button type="submit" size="sm">Claim</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td class="num"><strong>Outstanding cash</strong></td>
|
||||||
|
<td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{:else if activeTab === 'chores'}
|
||||||
|
<Card cols={3} scrollX title="Chore completions">
|
||||||
|
{#if sortedChoreCompletions.length === 0}
|
||||||
|
<p class="empty">No chore completions yet.</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Chore</th>
|
||||||
|
<th class="num">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedChoreCompletions as c}
|
||||||
|
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||||
|
<tr>
|
||||||
|
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||||
|
{memberName(c.memberId)}
|
||||||
|
</td>
|
||||||
|
<td>{choreName(a)}</td>
|
||||||
|
<td class="num">
|
||||||
|
{#if a?.type === 'money'}£{Number(a.value).toFixed(2)}
|
||||||
|
{:else}{a?.value ?? 0} pts{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Card cols={3} scrollX title="Todo completions">
|
||||||
|
{#if sortedTodoCompletions.length === 0}
|
||||||
|
<p class="empty">No todos completed yet.</p>
|
||||||
|
{:else}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Todo</th>
|
||||||
|
<th>Due</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedTodoCompletions as c}
|
||||||
|
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||||
|
<tr>
|
||||||
|
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||||
|
{memberName(c.memberId)}
|
||||||
|
</td>
|
||||||
|
<td>{choreName(a)}</td>
|
||||||
|
<td class="date">{a?.completeBy ? formatDDMMYY(a.completeBy) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 3.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 999;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tr.claimed {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
tr.claimed td {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
tr.requested {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.status-badge.outstanding {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.status-badge.requested-status {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.status-badge.claimed-status {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
tfoot td {
|
||||||
|
border-top: 2px solid #d1d5db;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { famStore } from '$lib/stores/fam.svelte';
|
|
||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
|
|
||||||
let rewards = $state(famStore.initialized ? famStore.rewards : (data.rewards || []))
|
|
||||||
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
|
|
||||||
|
|
||||||
function memberName(memberId: string): string {
|
|
||||||
return famStore.memberMap().get(memberId)?.name || members.find((m: any) => m.id === memberId)?.name || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
function memberColor(memberId: string): string {
|
|
||||||
return famStore.memberMap().get(memberId)?.color || members.find((m: any) => m.id === memberId)?.color || '#6366f1';
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewardAmount(r: any): string {
|
|
||||||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
|
||||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
|
||||||
return r.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewardStatus(r: any): string {
|
|
||||||
if (r.status === 'unclaimed') return 'Outstanding';
|
|
||||||
if (r.status === 'requested') return 'Requested';
|
|
||||||
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
|
|
||||||
return 'Claimed';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOutstanding(r: any): boolean { return r.status === 'unclaimed'; }
|
|
||||||
function isRequested(r: any): boolean { return r.status === 'requested'; }
|
|
||||||
|
|
||||||
let sorted = $derived([...rewards].sort((a: any, b: any) => {
|
|
||||||
const da = a.date || a.id || '';
|
|
||||||
const db = b.date || b.id || '';
|
|
||||||
return da < db ? 1 : da > db ? -1 : 0;
|
|
||||||
}))
|
|
||||||
|
|
||||||
let toast = $state('')
|
|
||||||
|
|
||||||
async function fire() {
|
|
||||||
const { default: confetti } = await import('@hiseb/confetti');
|
|
||||||
confetti({ count: 80, size: 4, velocity: 500, fade: true, position: { x: window.innerWidth / 2, y: 0 } });
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg: string) {
|
|
||||||
toast = msg;
|
|
||||||
setTimeout(() => toast = '', 4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalOutstanding = $derived(
|
|
||||||
rewards
|
|
||||||
.filter((r: any) => r.status !== 'claimed')
|
|
||||||
.filter((r: any) => r.rewardType === 'cash')
|
|
||||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ViewHeader title="Reward Ledger" subtitle="All rewards earned by members" hero />
|
|
||||||
|
|
||||||
{#if toast}
|
|
||||||
<div class="toast">{toast}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<CardGrid>
|
|
||||||
<Card cols={3}>
|
|
||||||
{#if rewards.length === 0}
|
|
||||||
<p style="color:#9ca3af;text-align:center;padding:2rem">No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.</p>
|
|
||||||
{:else}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Member</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each sorted as r}
|
|
||||||
{@const outstanding = isOutstanding(r)}
|
|
||||||
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
|
||||||
<td class="date">{r.date?.slice(0, 10) || '—'}</td>
|
|
||||||
<td>
|
|
||||||
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
|
||||||
{memberName(r.memberId)}
|
|
||||||
</td>
|
|
||||||
<td>{r.label}</td>
|
|
||||||
<td class="num">{rewardAmount(r)}</td>
|
|
||||||
<td>
|
|
||||||
<span class="status-badge" class:outstanding class:requested-status={isRequested(r)} class:claimed-status={!outstanding && !isRequested(r)}>
|
|
||||||
{rewardStatus(r)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#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') { fire(); } }; }}>
|
|
||||||
<input name="id" type="hidden" value={r.id} />
|
|
||||||
<Button type="submit" size="sm">Claim</Button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
<td class="num"><strong>Outstanding cash</strong></td>
|
|
||||||
<td class="num"><strong>£{totalOutstanding.toFixed(2)}</strong></td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
</CardGrid>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #dc2626; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
|
||||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
||||||
th, td { padding: 0.5rem 0.6rem; border-bottom: 1px solid #e5e7eb; text-align: left; }
|
|
||||||
th { background: #f9fafb; font-weight: 600; position: sticky; top: 0; }
|
|
||||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
|
|
||||||
tr.claimed { opacity: 0.5; }
|
|
||||||
tr.claimed td { color: #9ca3af; }
|
|
||||||
tr.requested { background: #eff6ff; }
|
|
||||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
|
|
||||||
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
|
|
||||||
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
|
|
||||||
.status-badge.requested-status { background: #dbeafe; color: #1e40af; }
|
|
||||||
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
|
|
||||||
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
|
|
||||||
</style>
|
|
||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
"name": "famchamp-monorepo",
|
"name": "famchamp-monorepo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm -r --parallel dev",
|
"dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev",
|
||||||
|
"start": "pnpm dev",
|
||||||
"build": "pnpm -r build"
|
"build": "pnpm -r build"
|
||||||
},
|
},
|
||||||
"version": "0.2.0"
|
"version": "0.2.0"
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ async function main() {
|
|||||||
rel("memberId", ids.members!, true),
|
rel("memberId", ids.members!, true),
|
||||||
rel("assignedChoreId", ids.assigned_chores!, true),
|
rel("assignedChoreId", ids.assigned_chores!, true),
|
||||||
date("date"),
|
date("date"),
|
||||||
|
date("completedAt"),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+80
-30
@@ -10,6 +10,7 @@ import {
|
|||||||
wallClockToUtc,
|
wallClockToUtc,
|
||||||
resolveTz,
|
resolveTz,
|
||||||
nextPaydayAfter as nextPaydayAfterTz,
|
nextPaydayAfter as nextPaydayAfterTz,
|
||||||
|
periodWindow,
|
||||||
} from "../../timezone.ts";
|
} from "../../timezone.ts";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -613,7 +614,11 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
|||||||
payday: record.payday,
|
payday: record.payday,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
|
if (
|
||||||
|
body.payday !== undefined ||
|
||||||
|
body.paydayTime !== undefined ||
|
||||||
|
body.timezone !== undefined
|
||||||
|
) {
|
||||||
const patch: Record<string, string | number> = {};
|
const patch: Record<string, string | number> = {};
|
||||||
if (body.payday !== undefined) {
|
if (body.payday !== undefined) {
|
||||||
const payday = Number(body.payday);
|
const payday = Number(body.payday);
|
||||||
@@ -629,8 +634,14 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
|
|||||||
}
|
}
|
||||||
if (body.timezone !== undefined) {
|
if (body.timezone !== undefined) {
|
||||||
const timezone = String(body.timezone);
|
const timezone = String(body.timezone);
|
||||||
if (timezone !== "auto" && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
|
if (
|
||||||
return c.json({ error: "timezone must be an IANA name or 'auto'" }, 400);
|
timezone !== "auto" &&
|
||||||
|
!/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)
|
||||||
|
)
|
||||||
|
return c.json(
|
||||||
|
{ error: "timezone must be an IANA name or 'auto'" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
patch.timezone = timezone;
|
patch.timezone = timezone;
|
||||||
}
|
}
|
||||||
const record = await pb.update("fams", famId, patch);
|
const record = await pb.update("fams", famId, patch);
|
||||||
@@ -782,9 +793,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
|
|||||||
async function getFamSettings(famId: string): Promise<any> {
|
async function getFamSettings(famId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
(
|
(await pb.getList("settings", `famId = '${famId}'`)).items?.[0] || {}
|
||||||
await pb.getList("settings", `famId = '${famId}'`)
|
|
||||||
).items?.[0] || {}
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
@@ -818,7 +827,10 @@ app.patch("/api/admin/:famId/settings", requireAdmin, async (c) => {
|
|||||||
} else if (Object.keys(patch).length) {
|
} else if (Object.keys(patch).length) {
|
||||||
s = await pb.update("settings", s.id, patch);
|
s = await pb.update("settings", s.id, patch);
|
||||||
}
|
}
|
||||||
return c.json({ simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || "" });
|
return c.json({
|
||||||
|
simulateEow: !!s.simulateEow,
|
||||||
|
webhookUrl: s.webhookUrl || "",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleError(c, err);
|
return handleError(c, err);
|
||||||
}
|
}
|
||||||
@@ -842,7 +854,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
pb
|
pb
|
||||||
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
|
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
|
||||||
.catch(() => ({ items: [] })),
|
.catch(() => ({ items: [] })),
|
||||||
pb.getList("rewards", `famId = '${famId}'`).catch(() => ({ items: [] })),
|
pb
|
||||||
|
.getList("rewards", `famId = '${famId}'`)
|
||||||
|
.catch(() => ({ items: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let rewardPointsList: any[] = [];
|
let rewardPointsList: any[] = [];
|
||||||
@@ -914,7 +928,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
let current = 0;
|
let current = 0;
|
||||||
if (cfg.type === "threshold")
|
if (cfg.type === "threshold")
|
||||||
current = sourceComps.reduce((sum: number, c: any) => {
|
current = sourceComps.reduce((sum: number, c: any) => {
|
||||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
const ch = assignedList.find(
|
||||||
|
(a: any) => a.id === c.assignedChoreId,
|
||||||
|
);
|
||||||
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
|
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
else if (cfg.type === "count") current = sourceComps.length;
|
else if (cfg.type === "count") current = sourceComps.length;
|
||||||
@@ -927,7 +943,10 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
: members.items;
|
: members.items;
|
||||||
for (const m of targets) {
|
for (const m of targets) {
|
||||||
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
||||||
const current = tryEval(m, periodCompletions.filter((c: any) => c.memberId === m.id));
|
const current = tryEval(
|
||||||
|
m,
|
||||||
|
periodCompletions.filter((c: any) => c.memberId === m.id),
|
||||||
|
);
|
||||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||||
predictedRewards.push({
|
predictedRewards.push({
|
||||||
config: cfg.name,
|
config: cfg.name,
|
||||||
@@ -969,9 +988,7 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
|
|||||||
const eligible = qualified.length
|
const eligible = qualified.length
|
||||||
? qualified
|
? qualified
|
||||||
: scored.filter((st) => st.current > 0);
|
: scored.filter((st) => st.current > 0);
|
||||||
const winner = eligible.sort(
|
const winner = eligible.sort((aa, bb) => bb.current - aa.current)[0];
|
||||||
(aa, bb) => bb.current - aa.current,
|
|
||||||
)[0];
|
|
||||||
if (winner)
|
if (winner)
|
||||||
predictedRewards.push({
|
predictedRewards.push({
|
||||||
config: cfg.name,
|
config: cfg.name,
|
||||||
@@ -1299,7 +1316,9 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
const tzEval = await getFamTimezone(famId);
|
const tzEval = await getFamTimezone(famId);
|
||||||
|
|
||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : "";
|
const pStart2 = cfg.period
|
||||||
|
? periodStart(cfg.period, paydayEval, tzEval)
|
||||||
|
: "";
|
||||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
|
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
|
||||||
const periodCompletions = cfg.period
|
const periodCompletions = cfg.period
|
||||||
? allCompletions.items.filter(
|
? allCompletions.items.filter(
|
||||||
@@ -1397,7 +1416,9 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
if (!achieved && existingRewards.length > 0) {
|
if (!achieved && existingRewards.length > 0) {
|
||||||
for (const r of existingRewards) {
|
for (const r of existingRewards) {
|
||||||
if (r.status !== "claimed") {
|
if (r.status !== "claimed") {
|
||||||
try { await pb.delete("rewards", r.id); } catch {}
|
try {
|
||||||
|
await pb.delete("rewards", r.id);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -1454,11 +1475,11 @@ async function evaluateFam(famId: string): Promise<void> {
|
|||||||
if (existingRewards.length > 0) {
|
if (existingRewards.length > 0) {
|
||||||
const existing = existingRewards[0];
|
const existing = existingRewards[0];
|
||||||
const stillValid =
|
const stillValid =
|
||||||
winner &&
|
winner && existing.memberId === winner.memberId && winner.current > 0;
|
||||||
existing.memberId === winner.memberId &&
|
|
||||||
winner.current > 0;
|
|
||||||
if (!stillValid && existing.status !== "claimed") {
|
if (!stillValid && existing.status !== "claimed") {
|
||||||
try { await pb.delete("rewards", existing.id); } catch {}
|
try {
|
||||||
|
await pb.delete("rewards", existing.id);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1653,13 +1674,26 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
|
|||||||
if (!assignedChoreId || !date) {
|
if (!assignedChoreId || !date) {
|
||||||
return c.json({ error: "assignedChoreId and date required" }, 400);
|
return c.json({ error: "assignedChoreId and date required" }, 400);
|
||||||
}
|
}
|
||||||
const nextDay = new Date(new Date(date + "T00:00:00Z").getTime() + 86400000)
|
// Todos are one-off: any existing completion means it's done, regardless of date.
|
||||||
.toISOString()
|
const chore = await pb
|
||||||
.slice(0, 10);
|
.getList(
|
||||||
const existing = await pb.getList(
|
"assigned_chores",
|
||||||
"completions",
|
`famId = '${famId}' && id = '${assignedChoreId}'`,
|
||||||
`assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${date}' && date < '${nextDay}'`,
|
)
|
||||||
);
|
.then((r) => r.items?.[0]);
|
||||||
|
const isTodo = chore?.isTodo;
|
||||||
|
let filter: string;
|
||||||
|
if (isTodo) {
|
||||||
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
|
||||||
|
} else {
|
||||||
|
// Scope to the chore's period: daily = today, weekly = the current week.
|
||||||
|
// Otherwise a weekly chore completed yesterday would be toggleable again today.
|
||||||
|
const payday = await getFamPayday(famId);
|
||||||
|
const tz = await getFamTimezone(famId);
|
||||||
|
const { from, to } = periodWindow(chore?.frequency, payday, tz);
|
||||||
|
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
|
||||||
|
}
|
||||||
|
const existing = await pb.getList("completions", filter);
|
||||||
if (existing.items?.length > 0) {
|
if (existing.items?.length > 0) {
|
||||||
await pb.delete("completions", existing.items[0].id);
|
await pb.delete("completions", existing.items[0].id);
|
||||||
evaluateFam(famId).catch(() => {});
|
evaluateFam(famId).catch(() => {});
|
||||||
@@ -1670,6 +1704,7 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
|
|||||||
memberId,
|
memberId,
|
||||||
assignedChoreId,
|
assignedChoreId,
|
||||||
date,
|
date,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
evaluateFam(famId).catch(() => {});
|
evaluateFam(famId).catch(() => {});
|
||||||
return c.json({ completed: true, record });
|
return c.json({ completed: true, record });
|
||||||
@@ -1971,7 +2006,10 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
|
|||||||
const memberId = c.get("memberId");
|
const memberId = c.get("memberId");
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const tz = await getFamTimezone(famId);
|
const tz = await getFamTimezone(famId);
|
||||||
const found = await pb.getList("rewards", `famId = '${famId}' && id = '${id}'`);
|
const found = await pb.getList(
|
||||||
|
"rewards",
|
||||||
|
`famId = '${famId}' && id = '${id}'`,
|
||||||
|
);
|
||||||
const reward = found.items?.[0];
|
const reward = found.items?.[0];
|
||||||
if (!reward) return c.json({ error: "Reward not found" }, 404);
|
if (!reward) return c.json({ error: "Reward not found" }, 404);
|
||||||
try {
|
try {
|
||||||
@@ -2097,7 +2135,12 @@ async function releaseWeek(famId: string) {
|
|||||||
const paydayTime = fam.paydayTime || "18:00";
|
const paydayTime = fam.paydayTime || "18:00";
|
||||||
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
|
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
|
||||||
if (Date.now() < target.getTime()) {
|
if (Date.now() < target.getTime()) {
|
||||||
return { settled: false, notYet: true, weekStart: wsToday, target: target.toISOString() };
|
return {
|
||||||
|
settled: false,
|
||||||
|
notYet: true,
|
||||||
|
weekStart: wsToday,
|
||||||
|
target: target.toISOString(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
|
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
|
||||||
@@ -2119,7 +2162,10 @@ async function releaseWeek(famId: string) {
|
|||||||
const unpaid = (cashRewards.items || []).filter(
|
const unpaid = (cashRewards.items || []).filter(
|
||||||
(r: any) => r.memberId === m.id && r.status !== "claimed",
|
(r: any) => r.memberId === m.id && r.status !== "claimed",
|
||||||
);
|
);
|
||||||
const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
|
const total = unpaid.reduce(
|
||||||
|
(sum: number, r: any) => sum + Number(r.value),
|
||||||
|
0,
|
||||||
|
);
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
|
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
|
||||||
// the parent's Issue list, but keep them as individual rows (Issue All totals).
|
// the parent's Issue list, but keep them as individual rows (Issue All totals).
|
||||||
@@ -2145,7 +2191,11 @@ async function releaseWeek(famId: string) {
|
|||||||
memberId: m.id,
|
memberId: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
total,
|
total,
|
||||||
rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })),
|
rewards: unpaid.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
value: Number(r.value),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1377,5 +1377,35 @@ export async function migrate(): Promise<void> {
|
|||||||
console.log(` ↳ assigned_chores collection not found (will be created by seed)`);
|
console.log(` ↳ assigned_chores collection not found (will be created by seed)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 8. Add completedAt timestamp to completions ──
|
||||||
|
const complCol = await getCollection("completions");
|
||||||
|
if (complCol) {
|
||||||
|
const hasCompletedAt = complCol.fields.some((f: any) => f.name === "completedAt");
|
||||||
|
if (!hasCompletedAt) {
|
||||||
|
console.log("[migrate] Adding completions.completedAt...");
|
||||||
|
complCol.fields.push({
|
||||||
|
name: "completedAt",
|
||||||
|
type: "date",
|
||||||
|
required: false,
|
||||||
|
hidden: false,
|
||||||
|
});
|
||||||
|
await updateCollection(complCol.id, {
|
||||||
|
name: "completions",
|
||||||
|
type: "base",
|
||||||
|
listRule: complCol.listRule,
|
||||||
|
viewRule: complCol.viewRule,
|
||||||
|
createRule: complCol.createRule,
|
||||||
|
updateRule: complCol.updateRule,
|
||||||
|
deleteRule: complCol.deleteRule,
|
||||||
|
fields: complCol.fields,
|
||||||
|
});
|
||||||
|
console.log(" ✓ completions.completedAt added");
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ completions.completedAt already exists`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` ↳ completions collection not found (will be created by seed)`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[migrate] Done");
|
console.log("[migrate] Done");
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-4
@@ -53,13 +53,17 @@ export function addDaysStr(dateStr: string, days: number): string {
|
|||||||
export function weekStart(payday: number, tz: string): string {
|
export function weekStart(payday: number, tz: string): string {
|
||||||
const today = todayInTz(tz);
|
const today = todayInTz(tz);
|
||||||
const wd = weekdayInTz(new Date(), tz);
|
const wd = weekdayInTz(new Date(), tz);
|
||||||
const back = ((wd - payday) % 7 + 7) % 7;
|
const back = (((wd - payday) % 7) + 7) % 7;
|
||||||
return addDaysStr(today, -back);
|
return addDaysStr(today, -back);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first configured-payday day strictly after dateStr (used to gate
|
// The first configured-payday day strictly after dateStr (used to gate
|
||||||
// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday.
|
// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday.
|
||||||
export function nextPaydayAfter(dateStr: string, payday: number, tz: string): string {
|
export function nextPaydayAfter(
|
||||||
|
dateStr: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): string {
|
||||||
let d = addDaysStr(dateStr, 1);
|
let d = addDaysStr(dateStr, 1);
|
||||||
while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) {
|
while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) {
|
||||||
d = addDaysStr(d, 1);
|
d = addDaysStr(d, 1);
|
||||||
@@ -82,7 +86,11 @@ function monthEndStr(month?: string): string {
|
|||||||
return `${month}-${String(lastDay).padStart(2, "0")}`;
|
return `${month}-${String(lastDay).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function periodStart(period: string, payday: number, tz: string): string {
|
export function periodStart(
|
||||||
|
period: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): string {
|
||||||
if (period === "daily") return todayInTz(tz);
|
if (period === "daily") return todayInTz(tz);
|
||||||
if (period === "weekly") return weekStart(payday, tz);
|
if (period === "weekly") return weekStart(payday, tz);
|
||||||
if (period === "monthly") return monthStartStr();
|
if (period === "monthly") return monthStartStr();
|
||||||
@@ -96,7 +104,42 @@ export function periodEnd(period: string, start: string): string {
|
|||||||
return start;
|
return start;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wallClockToUtc(dateStr: string, time: string, tz: string): number {
|
// The completion window a chore of a given frequency is "done for" in the
|
||||||
|
// current period. Weekly chores are done once per week ([weekStart, +7)),
|
||||||
|
// daily chores once per day ([today, +1)). Half-open [from, to).
|
||||||
|
export function periodWindow(
|
||||||
|
frequency: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): { from: string; to: string } {
|
||||||
|
if (frequency === "weekly") {
|
||||||
|
const from = weekStart(payday, tz);
|
||||||
|
return { from, to: addDaysStr(from, 7) };
|
||||||
|
}
|
||||||
|
const from = todayInTz(tz);
|
||||||
|
return { from, to: addDaysStr(from, 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if any completion date falls within the current period window for the
|
||||||
|
// chore's frequency. Dates may be YYYY-MM-DD or ISO (time portion ignored).
|
||||||
|
export function isCompleteForPeriod(
|
||||||
|
frequency: string,
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
dates: string[],
|
||||||
|
): boolean {
|
||||||
|
const { from, to } = periodWindow(frequency, payday, tz);
|
||||||
|
return dates.some((d) => {
|
||||||
|
const day = (d || "").slice(0, 10);
|
||||||
|
return day >= from && day < to;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wallClockToUtc(
|
||||||
|
dateStr: string,
|
||||||
|
time: string,
|
||||||
|
tz: string,
|
||||||
|
): number {
|
||||||
const [y, m, d] = dateStr.split("-").map(Number);
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
const [h, min] = time.split(":").map(Number);
|
const [h, min] = time.split(":").map(Number);
|
||||||
let epoch = Date.UTC(y, m - 1, d, h, min);
|
let epoch = Date.UTC(y, m - 1, d, h, min);
|
||||||
|
|||||||
Reference in New Issue
Block a user