Compare commits
2 Commits
7ce127f482
...
3f95d50d2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f95d50d2e | |||
| 9d7915eb60 |
@@ -12,7 +12,7 @@ export async function sendPasswordResetEmail(opts: {
|
|||||||
const { to, resetLink, famName } = opts;
|
const { to, resetLink, famName } = opts;
|
||||||
|
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'no-reply@famchamp.ai',
|
from: 'no-reply@walthamstow.xyz',
|
||||||
to,
|
to,
|
||||||
subject: `Reset your password for ${famName}`,
|
subject: `Reset your password for ${famName}`,
|
||||||
html: `
|
html: `
|
||||||
@@ -33,7 +33,7 @@ export async function sendParentInviteEmail(opts: {
|
|||||||
const { to, inviteLink, famName, otp } = opts;
|
const { to, inviteLink, famName, otp } = opts;
|
||||||
|
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'no-reply@famchamp.ai',
|
from: 'no-reply@walthamstow.xyz',
|
||||||
to,
|
to,
|
||||||
subject: `You're invited to ${famName} on FamDone`,
|
subject: `You're invited to ${famName} on FamDone`,
|
||||||
html: `
|
html: `
|
||||||
@@ -58,7 +58,7 @@ export async function sendAccessUnlockedEmail(opts: {
|
|||||||
const vars = { famName, codeName, dashboardUrl };
|
const vars = { famName, codeName, dashboardUrl };
|
||||||
|
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'no-reply@famchamp.ai',
|
from: 'no-reply@walthamstow.xyz',
|
||||||
to,
|
to,
|
||||||
subject: template.subject(vars),
|
subject: template.subject(vars),
|
||||||
html: template.html(vars)
|
html: template.html(vars)
|
||||||
@@ -73,7 +73,7 @@ export async function sendWelcomeEmail(opts: {
|
|||||||
const { to, famName, dashboardUrl } = opts;
|
const { to, famName, dashboardUrl } = opts;
|
||||||
|
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'no-reply@famchamp.ai',
|
from: 'no-reply@walthamstow.xyz',
|
||||||
to,
|
to,
|
||||||
subject: `Welcome to FamDone${famName ? `, ${famName}` : ''}!`,
|
subject: `Welcome to FamDone${famName ? `, ${famName}` : ''}!`,
|
||||||
html: `
|
html: `
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ import {
|
|||||||
bonusWindow,
|
bonusWindow,
|
||||||
completionInWindow
|
completionInWindow
|
||||||
} from '@shared/timezone';
|
} from '@shared/timezone';
|
||||||
|
import { computeBonusProgress } from '@shared/bonus-progress';
|
||||||
import { famMeta } from './fam';
|
import { famMeta } from './fam';
|
||||||
|
|
||||||
function resolveServerTz(tz?: string): string {
|
function resolveServerTz(tz?: string): string {
|
||||||
return resolveTz(tz || 'auto');
|
return resolveTz(tz || 'auto');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRewardValue = (cfg: any): number => {
|
||||||
|
const n = Number(cfg.rewardValue);
|
||||||
|
// Prize rewards carry the payout in `label`/rewardValue text ("fluffy toy"),
|
||||||
|
// not a number — but PB rejects value 0 on the required number field, so
|
||||||
|
// fall back to 1 and let the label carry the meaning.
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||||
|
};
|
||||||
|
|
||||||
function claimableStamp(cfg: any, payday: number, tz: string) {
|
function claimableStamp(cfg: any, payday: number, tz: string) {
|
||||||
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
|
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
|
||||||
return { claimable: 'immediate', settleDate: '' };
|
return { claimable: 'immediate', settleDate: '' };
|
||||||
@@ -25,7 +34,11 @@ function claimableStamp(cfg: any, payday: number, tz: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function targetChoreFor(cfg: any, memberId: string): string | undefined {
|
function targetChoreFor(cfg: any, memberId: string): string | undefined {
|
||||||
if (cfg.targetChoreIds && typeof cfg.targetChoreIds === 'object' && cfg.targetChoreIds[memberId]) {
|
if (
|
||||||
|
cfg.targetChoreIds &&
|
||||||
|
typeof cfg.targetChoreIds === 'object' &&
|
||||||
|
cfg.targetChoreIds[memberId]
|
||||||
|
) {
|
||||||
return cfg.targetChoreIds[memberId];
|
return cfg.targetChoreIds[memberId];
|
||||||
}
|
}
|
||||||
return cfg.targetChoreId || undefined;
|
return cfg.targetChoreId || undefined;
|
||||||
@@ -57,7 +70,8 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
// `percent` threshold type to compute % of chores completed.
|
// `percent` threshold type to compute % of chores completed.
|
||||||
const dueByMember: Record<string, number> = {};
|
const dueByMember: Record<string, number> = {};
|
||||||
for (const a of allAssigned) {
|
for (const a of allAssigned) {
|
||||||
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
if (a.memberId)
|
||||||
|
dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
||||||
}
|
}
|
||||||
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
||||||
|
|
||||||
@@ -81,7 +95,7 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
memberId,
|
memberId,
|
||||||
bonusConfigId: cfg.id,
|
bonusConfigId: cfg.id,
|
||||||
label,
|
label,
|
||||||
value: Number(cfg.rewardValue) || 0,
|
value: getRewardValue(cfg),
|
||||||
rewardType: cfg.rewardType,
|
rewardType: cfg.rewardType,
|
||||||
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||||||
claimedAt: cfg.rewardType === 'points' ? now : null,
|
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||||||
@@ -91,11 +105,16 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (cfg.target === 'individual') {
|
if (cfg.target === 'individual') {
|
||||||
const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers;
|
const targetMembers = cfg.memberId
|
||||||
|
? allMembers.filter((m: any) => m.id === cfg.memberId)
|
||||||
|
: allMembers;
|
||||||
for (const m of targetMembers) {
|
for (const m of targetMembers) {
|
||||||
const memberTarget = targetChoreFor(cfg, m.id);
|
const memberTarget = targetChoreFor(cfg, m.id);
|
||||||
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||||
if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget);
|
if (memberTarget)
|
||||||
|
memberCompletions = memberCompletions.filter(
|
||||||
|
(c: any) => c.assignedChoreId === memberTarget
|
||||||
|
);
|
||||||
let current = 0;
|
let current = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
if (cfg.thresholdType === 'percent') {
|
if (cfg.thresholdType === 'percent') {
|
||||||
@@ -133,7 +152,10 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
} else if (cfg.target === 'collaborative') {
|
} else if (cfg.target === 'collaborative') {
|
||||||
const allMemberIds = allMembers.map((m: any) => m.id);
|
const allMemberIds = allMembers.map((m: any) => m.id);
|
||||||
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
||||||
if (cfg.targetChoreId) teamCompletions = teamCompletions.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
|
if (cfg.targetChoreId)
|
||||||
|
teamCompletions = teamCompletions.filter(
|
||||||
|
(c: any) => c.assignedChoreId === cfg.targetChoreId
|
||||||
|
);
|
||||||
let total = 0;
|
let total = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
if (cfg.thresholdType === 'percent') {
|
if (cfg.thresholdType === 'percent') {
|
||||||
@@ -170,7 +192,10 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
const scored = allMembers.map((m: any) => {
|
const scored = allMembers.map((m: any) => {
|
||||||
const memberTarget = targetChoreFor(cfg, m.id);
|
const memberTarget = targetChoreFor(cfg, m.id);
|
||||||
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||||||
if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget);
|
if (memberTarget)
|
||||||
|
memberCompletions = memberCompletions.filter(
|
||||||
|
(c: any) => c.assignedChoreId === memberTarget
|
||||||
|
);
|
||||||
let current = 0;
|
let current = 0;
|
||||||
if (cfg.type === 'threshold') {
|
if (cfg.type === 'threshold') {
|
||||||
if (cfg.thresholdType === 'percent') {
|
if (cfg.thresholdType === 'percent') {
|
||||||
@@ -187,7 +212,9 @@ export async function evaluateFam(pb: any, famId: string) {
|
|||||||
}
|
}
|
||||||
return { memberId: m.id, name: m.name, current };
|
return { memberId: m.id, name: m.name, current };
|
||||||
});
|
});
|
||||||
const qualified = scored.filter((s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue));
|
const qualified = scored.filter(
|
||||||
|
(s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue)
|
||||||
|
);
|
||||||
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
|
const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0);
|
||||||
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
|
const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0];
|
||||||
|
|
||||||
@@ -233,106 +260,13 @@ export async function progress(pb: any, famId: string) {
|
|||||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const assignedList = assigned;
|
|
||||||
const completionsList = completions;
|
|
||||||
const dueByMember: Record<string, number> = {};
|
|
||||||
for (const a of assignedList) {
|
|
||||||
if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1);
|
|
||||||
}
|
|
||||||
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
|
||||||
let allRewards: any[] = [];
|
let allRewards: any[] = [];
|
||||||
try {
|
try {
|
||||||
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Shared with the Rewards page client-side realtime recompute (famStore + SSE),
|
||||||
const result: any[] = [];
|
// so the admin kanban and member dashboard always show identical progress.
|
||||||
|
return computeBonusProgress(configsData, members, assigned, completions, allRewards, payday, tz);
|
||||||
for (const cfg of configsData) {
|
|
||||||
const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
|
||||||
const win = bonusWindow(cfg, payday, tz);
|
|
||||||
const pStart = win.from;
|
|
||||||
const pEnd = win.to;
|
|
||||||
const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
|
|
||||||
|
|
||||||
const progressRows: any[] = [];
|
|
||||||
|
|
||||||
if (cfg.target === 'collaborative') {
|
|
||||||
let teamCompletions = periodCompletions.filter((c: any) =>
|
|
||||||
members.some((m: any) => m.id === c.memberId)
|
|
||||||
);
|
|
||||||
if (cfg.targetChoreId) teamCompletions = teamCompletions.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
|
|
||||||
let teamCurrent = 0;
|
|
||||||
if (cfg.type === 'threshold') {
|
|
||||||
if (cfg.thresholdType === 'percent') {
|
|
||||||
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
|
|
||||||
} else {
|
|
||||||
teamCurrent = teamCompletions.reduce((sum: number, c: any) => {
|
|
||||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
|
||||||
teamCurrent = teamCompletions.length;
|
|
||||||
}
|
|
||||||
const teamReward = cfgRewards[0];
|
|
||||||
progressRows.push({
|
|
||||||
memberId: '__team__',
|
|
||||||
memberName: 'Team Total',
|
|
||||||
memberColor: '#8b5cf6',
|
|
||||||
current: teamCurrent,
|
|
||||||
criteriaValue: cfg.criteriaValue || 0,
|
|
||||||
reward: teamReward ? { id: teamReward.id, status: teamReward.status } : null,
|
|
||||||
state: teamReward ? teamReward.status : 'pending',
|
|
||||||
achieved: teamReward ? true : false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cfg.target !== 'collaborative') {
|
|
||||||
const progressMembers =
|
|
||||||
cfg.target === 'individual' && cfg.memberId
|
|
||||||
? members.filter((m: any) => m.id === cfg.memberId)
|
|
||||||
: members;
|
|
||||||
for (const m of progressMembers) {
|
|
||||||
const memberTarget = targetChoreFor(cfg, m.id);
|
|
||||||
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
|
||||||
if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget);
|
|
||||||
const memberReward = cfgRewards.find((r: any) => r.memberId === m.id);
|
|
||||||
|
|
||||||
let current = 0;
|
|
||||||
if (cfg.type === 'threshold') {
|
|
||||||
if (cfg.thresholdType === 'percent') {
|
|
||||||
const due = dueByMember[m.id] || 0;
|
|
||||||
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
|
||||||
} else {
|
|
||||||
current = memberCompletions.reduce((sum: number, c: any) => {
|
|
||||||
const chore = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
|
||||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
} else if (cfg.type === 'count') {
|
|
||||||
current = memberCompletions.length;
|
|
||||||
} else if (cfg.type === 'manual') {
|
|
||||||
current = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
progressRows.push({
|
|
||||||
memberId: m.id,
|
|
||||||
memberName: m.name,
|
|
||||||
memberColor: m.color,
|
|
||||||
current,
|
|
||||||
criteriaValue: cfg.criteriaValue || 0,
|
|
||||||
reward: memberReward ? { id: memberReward.id, status: memberReward.status } : null,
|
|
||||||
state: memberReward ? memberReward.status : 'pending',
|
|
||||||
achieved: memberReward ? true : false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({ config: cfg, progress: progressRows, periodStart: pStart, periodEnd: pEnd });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
|
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
|
||||||
@@ -373,9 +307,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId
|
|||||||
const pEnd = periodEnd(cfg.period, pStart);
|
const pEnd = periodEnd(cfg.period, pStart);
|
||||||
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
|
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
|
||||||
if (periodRewards.length > 0) {
|
if (periodRewards.length > 0) {
|
||||||
throw new Error(
|
throw new Error(`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`);
|
||||||
`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +324,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId
|
|||||||
memberId: m.id,
|
memberId: m.id,
|
||||||
bonusConfigId: cfg.id,
|
bonusConfigId: cfg.id,
|
||||||
label,
|
label,
|
||||||
value: Number(cfg.rewardValue) || 0,
|
value: getRewardValue(cfg),
|
||||||
rewardType: cfg.rewardType,
|
rewardType: cfg.rewardType,
|
||||||
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||||||
claimedAt: cfg.rewardType === 'points' ? now : null,
|
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ export async function load(event) {
|
|||||||
const s = createServices(pbUser(event), session);
|
const s = createServices(pbUser(event), session);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [members, assigned, summary, fam, stats] = await Promise.all([
|
const [members, assigned, summary, fam, stats, rewards, bonusConfigs] = await Promise.all([
|
||||||
s.crud.list('members', famId),
|
s.crud.list('members', famId),
|
||||||
s.crud.list('assigned-chores', famId),
|
s.crud.list('assigned-chores', famId),
|
||||||
s.fam.weeklySummary(famId),
|
s.fam.weeklySummary(famId),
|
||||||
s.fam.get(famId),
|
s.fam.get(famId),
|
||||||
s.stats.family(famId)
|
s.stats.family(famId),
|
||||||
|
s.crud.list('rewards', famId),
|
||||||
|
s.crud.list('bonus-configs', famId)
|
||||||
]);
|
]);
|
||||||
return { members, assigned, summary, fam, stats };
|
return { members, assigned, summary, fam, stats, rewards, bonusConfigs };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||||
import { formatDDMMYY } from '$lib/format';
|
import { formatDDMMYY } from '$lib/format';
|
||||||
|
import { famStore } from '$lib/stores/fam.svelte';
|
||||||
import { handleOf } from '@shared/slugify';
|
import { handleOf } from '@shared/slugify';
|
||||||
import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone';
|
import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone';
|
||||||
|
|
||||||
@@ -54,6 +55,28 @@
|
|||||||
let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
|
let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
|
||||||
let allTimeChores = $derived(Number(stats.allTimeChores) || 0);
|
let allTimeChores = $derived(Number(stats.allTimeChores) || 0);
|
||||||
|
|
||||||
|
// ─── Goals completed (rewards from bonus configs, pocket money excluded) ───
|
||||||
|
// Derived from famStore so realtime updates (a goal met / revoked) flow in.
|
||||||
|
let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
|
||||||
|
let bonusConfigs = $derived(
|
||||||
|
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
|
||||||
|
);
|
||||||
|
let goalsByMember = $derived.by(() => {
|
||||||
|
const ws = (summary?.weekStart as string) || todayInTz(famTz);
|
||||||
|
const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id));
|
||||||
|
const map = new Map<string, { count: number; names: string[] }>();
|
||||||
|
for (const r of rewards as any[]) {
|
||||||
|
if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue;
|
||||||
|
if ((r.date || '').slice(0, 10) < ws) continue;
|
||||||
|
const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId);
|
||||||
|
const entry = map.get(r.memberId) || { count: 0, names: [] };
|
||||||
|
entry.count++;
|
||||||
|
if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name);
|
||||||
|
map.set(r.memberId, entry);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Trend chart window: show current month, expand a month at a time up to 3. ───
|
// ─── Trend chart window: show current month, expand a month at a time up to 3. ───
|
||||||
let trend = $derived.by(() => {
|
let trend = $derived.by(() => {
|
||||||
const series = Array.isArray(stats.series) ? stats.series : [];
|
const series = Array.isArray(stats.series) ? stats.series : [];
|
||||||
@@ -340,6 +363,7 @@
|
|||||||
{@const scPot = dailyPointsPotential(s.memberId)}
|
{@const scPot = dailyPointsPotential(s.memberId)}
|
||||||
{@const scVal = avgDailyPointsFor(s)}
|
{@const scVal = avgDailyPointsFor(s)}
|
||||||
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
|
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
|
||||||
|
{@const goals = goalsByMember.get(s.memberId)}
|
||||||
<div class="column col-member">
|
<div class="column col-member">
|
||||||
<div class="member-head">
|
<div class="member-head">
|
||||||
<span class="dot" style="background:{s.memberColor}"></span>
|
<span class="dot" style="background:{s.memberColor}"></span>
|
||||||
@@ -378,6 +402,12 @@
|
|||||||
<span class="stat-lbl">Done</span>
|
<span class="stat-lbl">Done</span>
|
||||||
<span class="stat-val">{wkDone}/{wkTotal}</span>
|
<span class="stat-val">{wkDone}/{wkTotal}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-lbl">Goals 🎉</span>
|
||||||
|
<span class="stat-val goals" title={goals?.names.join(' · ')}
|
||||||
|
>{goals?.count ?? 0}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -656,6 +686,9 @@
|
|||||||
.stat-val.cash {
|
.stat-val.cash {
|
||||||
color: #059669;
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
.stat-val.goals {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Chart ── */
|
/* ── Chart ── */
|
||||||
.chart-wrap {
|
.chart-wrap {
|
||||||
|
|||||||
@@ -163,6 +163,34 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Goals completed feed for the admin overview — every materialized bonus
|
||||||
|
// reward (prize / points / cash), newest first. Undoing the underlying
|
||||||
|
// chore/points deletes the unclaimed reward server-side, so this list
|
||||||
|
// shrinks again in realtime via famStore.
|
||||||
|
let parentGoalsCompleted = $derived.by(() =>
|
||||||
|
parentRewards
|
||||||
|
.filter((r: any) => r.bonusConfigId)
|
||||||
|
.map((r: any) => {
|
||||||
|
const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId);
|
||||||
|
const m = parentMembers.find((x: any) => x.id === r.memberId);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
goalName: cfg?.name || r.label,
|
||||||
|
memberName: m?.name || 'Unknown',
|
||||||
|
color: m?.color || '#6366f1',
|
||||||
|
rewardText:
|
||||||
|
r.rewardType === 'cash'
|
||||||
|
? `£${Number(r.value).toFixed(2)}`
|
||||||
|
: r.rewardType === 'points'
|
||||||
|
? `${r.value} pts`
|
||||||
|
: cfg?.rewardValue || r.label,
|
||||||
|
date: (r.date || '').slice(0, 10)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||||
|
.slice(0, 10)
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Child View (kanban) ───
|
// ─── Child View (kanban) ───
|
||||||
let memberId = $state(data.memberId || '');
|
let memberId = $state(data.memberId || '');
|
||||||
let pbToken = $state(data.token || '');
|
let pbToken = $state(data.token || '');
|
||||||
@@ -179,8 +207,7 @@
|
|||||||
: (data.templates as ChoreTemplate[])
|
: (data.templates as ChoreTemplate[])
|
||||||
);
|
);
|
||||||
let assigned = $derived(
|
let assigned = $derived(
|
||||||
(
|
(famStore.initialized
|
||||||
famStore.initialized
|
|
||||||
? (famStore.assigned as AssignedChore[])
|
? (famStore.assigned as AssignedChore[])
|
||||||
: (data.assigned as AssignedChore[])
|
: (data.assigned as AssignedChore[])
|
||||||
).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a))
|
).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a))
|
||||||
@@ -489,8 +516,11 @@
|
|||||||
const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label;
|
const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label;
|
||||||
map.set(name, (map.get(name) || 0) + 1);
|
map.set(name, (map.get(name) || 0) + 1);
|
||||||
}
|
}
|
||||||
return [...map.entries()].map(([name, count]) => ({ name, count }));
|
// Only badge recurring/multiple bonuses — a single occurrence (×1) is
|
||||||
|
// already obvious from the wallet, so it just adds noise here.
|
||||||
|
return [...map.entries()].map(([name, count]) => ({ name, count })).filter((t) => t.count > 1);
|
||||||
});
|
});
|
||||||
|
let anyWeekBonuses = $derived(weekRewards.length > 0);
|
||||||
|
|
||||||
// ─── Threshold Goals (bonus configs the member is chasing) ───
|
// ─── Threshold Goals (bonus configs the member is chasing) ───
|
||||||
let thresholdGoals = $derived.by(() => {
|
let thresholdGoals = $derived.by(() => {
|
||||||
@@ -608,6 +638,71 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Goal celebrations (bonus rewards achieved) ───
|
||||||
|
// A goal flips to achieved the moment its completions cross the threshold.
|
||||||
|
// We celebrate once per goal per day (localStorage, so the notice survives
|
||||||
|
// reloads but expires at the next 00:00), re-arm when the achievement is
|
||||||
|
// revoked (chore/points undone), and always show the reward value/prize.
|
||||||
|
const goalCelebratedKey = `goalCelebrated:${famId}`;
|
||||||
|
let goalCelebrated = $state<Record<string, string>>(
|
||||||
|
typeof localStorage !== 'undefined'
|
||||||
|
? (JSON.parse(localStorage.getItem(goalCelebratedKey) || '{}') as Record<string, string>)
|
||||||
|
: {}
|
||||||
|
);
|
||||||
|
let goalCelebrations = $state<{ id: string; name: string; reward: string }[]>([]);
|
||||||
|
|
||||||
|
function goalRewardLabel(g: { config: any }): string {
|
||||||
|
const cfg = g.config;
|
||||||
|
if (cfg.rewardType === 'cash') return `£${Number(cfg.rewardValue).toFixed(2)}`;
|
||||||
|
if (cfg.rewardType === 'prize') return cfg.rewardValue || 'prize';
|
||||||
|
return `${cfg.rewardValue} pts`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissGoalCelebrate(id: string) {
|
||||||
|
goalCelebrations = goalCelebrations.filter((c) => c.id !== id);
|
||||||
|
goalCelebrated = { ...goalCelebrated, [id]: todayChild };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(goalCelebratedKey, JSON.stringify(goalCelebrated));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (role !== 'child') return;
|
||||||
|
const today = todayChild;
|
||||||
|
let next: Record<string, string> = { ...goalCelebrated };
|
||||||
|
let changed = false;
|
||||||
|
// Yesterday's celebrations expire — a notice lives for its day only.
|
||||||
|
for (const [id, day] of Object.entries(next)) {
|
||||||
|
if (day !== today) {
|
||||||
|
delete next[id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const g of thresholdGoals) {
|
||||||
|
if (g.achieved && next[g.config.id] !== today) {
|
||||||
|
next[g.config.id] = today;
|
||||||
|
changed = true;
|
||||||
|
goalCelebrations = [
|
||||||
|
{ id: g.config.id, name: g.config.name, reward: goalRewardLabel(g) },
|
||||||
|
...goalCelebrations.filter((c) => c.id !== g.config.id)
|
||||||
|
];
|
||||||
|
fire();
|
||||||
|
} else if (!g.achieved && next[g.config.id]) {
|
||||||
|
// Achievement revoked (chore/points undone) — drop the notice and
|
||||||
|
// re-arm so completing it again celebrates again.
|
||||||
|
delete next[g.config.id];
|
||||||
|
changed = true;
|
||||||
|
goalCelebrations = goalCelebrations.filter((c) => c.id !== g.config.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
goalCelebrated = next;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(goalCelebratedKey, JSON.stringify(next));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function shortDate(dateStr: string): string {
|
function shortDate(dateStr: string): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
@@ -802,6 +897,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- GOALS COMPLETED (mini cards, above the member/trigger/claims grid) -->
|
||||||
|
<div class="goals-completed">
|
||||||
|
{#if parentGoalsCompleted.length === 0}
|
||||||
|
<p class="empty">
|
||||||
|
No goals completed yet — rewards appear here the moment a member hits a target.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="goal-mini-grid">
|
||||||
|
{#each parentGoalsCompleted as g}
|
||||||
|
<div class="goal-mini">
|
||||||
|
<div class="gm-top">
|
||||||
|
<span class="dot" style="background:{g.color}"></span>
|
||||||
|
<span class="gm-name">🎉 {g.goalName}</span>
|
||||||
|
<span class="gm-member">{g.memberName}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gm-bottom">
|
||||||
|
<span class="gm-reward">{g.rewardText}</span>
|
||||||
|
<span class="gm-date">{formatDDMMYY(g.date)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="kanban-scroll">
|
<div class="kanban-scroll">
|
||||||
<div class="kanban-inner">
|
<div class="kanban-inner">
|
||||||
<CardGrid stackTablet>
|
<CardGrid stackTablet>
|
||||||
@@ -1205,12 +1325,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
|
{#if anyWeekBonuses}
|
||||||
{#if weekBonusTallies.length > 0}
|
{#if weekBonusTallies.length > 0}
|
||||||
<div class="hero-pills">
|
<div class="hero-pills">
|
||||||
{#each weekBonusTallies as t}
|
{#each weekBonusTallies as t}
|
||||||
<span class="hero-pill">🎉 {t.name} ×{t.count}</span>
|
<span class="hero-pill">🎉 {t.name} ×{t.count}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="hero-null">no bonuses earned yet</span>
|
<span class="hero-null">no bonuses earned yet</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1260,6 +1382,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if goal.achieved}
|
||||||
|
<div class="goal-done">
|
||||||
|
<span class="goal-tick">{@html checkCircleIcon}</span>
|
||||||
|
<span class="goal-done-text">
|
||||||
|
<b>Reward earned</b>
|
||||||
|
<span>{goalRewardLabel(goal)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="goal-bar-wrap">
|
<div class="goal-bar-wrap">
|
||||||
<div class="goal-bar-fill" style="width:{pct}%"></div>
|
<div class="goal-bar-fill" style="width:{pct}%"></div>
|
||||||
{#if goal.targetPct < 100}
|
{#if goal.targetPct < 100}
|
||||||
@@ -1278,17 +1409,15 @@
|
|||||||
>{Math.min(goal.current, goal.target)} / {goal.target}</span
|
>{Math.min(goal.current, goal.target)} / {goal.target}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if goal.achieved}
|
|
||||||
<span class="goal-badge">{@html checkCircleIcon} earned</span>
|
|
||||||
{:else}
|
|
||||||
<span class="goal-pct">{pct}%</span>
|
<span class="goal-pct">{pct}%</span>
|
||||||
<span class="goal-target-pill"
|
<span class="goal-target-pill"
|
||||||
>{@html targetIcon} {goal.targetLabel}{#if goal.targetChoreName}
|
>{@html targetIcon}
|
||||||
|
{goal.targetLabel}{#if goal.targetChoreName}
|
||||||
<span class="goal-chore"> · {goal.targetChoreName}</span>
|
<span class="goal-chore"> · {goal.targetChoreName}</span>
|
||||||
{/if}</span
|
{/if}</span
|
||||||
>
|
>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -1311,7 +1440,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if celebrations.length > 0}
|
{#if celebrations.length > 0 || goalCelebrations.length > 0}
|
||||||
<div class="celebrations">
|
<div class="celebrations">
|
||||||
{#each celebrations as cel}
|
{#each celebrations as cel}
|
||||||
<div class="celebrate-card">
|
<div class="celebrate-card">
|
||||||
@@ -1322,6 +1451,16 @@
|
|||||||
<button class="celebrate-close" onclick={() => dismissCelebrate(cel.id)}>✕</button>
|
<button class="celebrate-close" onclick={() => dismissCelebrate(cel.id)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each goalCelebrations as cel}
|
||||||
|
<div class="celebrate-card goal">
|
||||||
|
<span class="celebrate-emoji">🎉</span>
|
||||||
|
<span class="celebrate-text"
|
||||||
|
><strong>{cel.name}</strong> complete — you earned <b>{cel.reward}</b>!</span
|
||||||
|
>
|
||||||
|
<button class="celebrate-close" onclick={() => dismissGoalCelebrate(cel.id)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="kanban">
|
<div class="kanban">
|
||||||
@@ -1451,12 +1590,8 @@
|
|||||||
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
||||||
<!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
|
<!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
|
||||||
<div class="life-badges">
|
<div class="life-badges">
|
||||||
<span class="life-badge life-cash"
|
<span class="life-badge life-cash">£{allTimeCash.toFixed(2)} <i>all time</i></span>
|
||||||
>£{allTimeCash.toFixed(2)} <i>all time</i></span
|
<span class="life-badge life-pts">{allTimePoints.toLocaleString()} <i>pts</i></span>
|
||||||
>
|
|
||||||
<span class="life-badge life-pts"
|
|
||||||
>{allTimePoints.toLocaleString()} <i>pts</i></span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1545,7 +1680,7 @@
|
|||||||
border-left: 4px solid #6366f1;
|
border-left: 4px solid #6366f1;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
background: white;
|
background: #f6fbff;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
transition:
|
transition:
|
||||||
transform 0.15s,
|
transform 0.15s,
|
||||||
@@ -1647,7 +1782,7 @@
|
|||||||
color: #059669;
|
color: #059669;
|
||||||
}
|
}
|
||||||
.done-item.odd {
|
.done-item.odd {
|
||||||
background: #f3f4f6;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
.done-icon {
|
.done-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -2283,6 +2418,133 @@
|
|||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Achieved goal — big tick + reward, progress hidden */
|
||||||
|
.goal-done {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0 0.15rem;
|
||||||
|
}
|
||||||
|
.goal-tick {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.goal-tick :global(svg) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.goal-done-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
.goal-done-text b {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.goal-done-text span {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #059669;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.goal-achieved .goal-done {
|
||||||
|
animation: goal-pop 0.35s ease;
|
||||||
|
}
|
||||||
|
@keyframes goal-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin overview — "Goals completed" mini cards */
|
||||||
|
.goals-completed {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.goal-mini-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.goal-mini-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.goal-mini-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.goal-mini {
|
||||||
|
background: linear-gradient(135deg, #d1d3fa, #ccfbf1);
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.gm-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.gm-top .dot {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.gm-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #7a43bf;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.gm-member {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.gm-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.gm-reward {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #047857;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.gm-date {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Goal celebration notice (child view) */
|
||||||
|
.celebrate-card.goal {
|
||||||
|
border-color: #86efac;
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Kanban ── */
|
/* ── Kanban ── */
|
||||||
.celebrations {
|
.celebrations {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,8 +6,13 @@
|
|||||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||||
import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone';
|
import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone';
|
||||||
|
import {
|
||||||
|
computeBonusProgress,
|
||||||
|
type BonusConfigWithProgress as BonusProgressResult
|
||||||
|
} from '@shared/bonus-progress';
|
||||||
import { chevronRight } from '$lib/components/icons';
|
import { chevronRight } from '$lib/components/icons';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
import Check from '@lucide/svelte/icons/check';
|
||||||
import Tag from '@lucide/svelte/icons/tag';
|
import Tag from '@lucide/svelte/icons/tag';
|
||||||
import UsersRound from '@lucide/svelte/icons/users-round';
|
import UsersRound from '@lucide/svelte/icons/users-round';
|
||||||
import Target from '@lucide/svelte/icons/target';
|
import Target from '@lucide/svelte/icons/target';
|
||||||
@@ -27,8 +32,7 @@
|
|||||||
BonusTemplate,
|
BonusTemplate,
|
||||||
Member,
|
Member,
|
||||||
AssignedChore,
|
AssignedChore,
|
||||||
BonusConfigWithProgress,
|
Completion,
|
||||||
BonusProgress,
|
|
||||||
Reward
|
Reward
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
|
||||||
@@ -116,11 +120,33 @@
|
|||||||
let allRewards = $derived(
|
let allRewards = $derived(
|
||||||
famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[]) || []
|
famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[]) || []
|
||||||
);
|
);
|
||||||
let progressData = $state((data.progress as BonusConfigWithProgress[]) || []);
|
|
||||||
|
// Progress is recomputed in realtime from famStore (PB SSE) using the same
|
||||||
|
// shared algorithm as the server loader — otherwise the kanban (counts,
|
||||||
|
// bars, achieved states) goes stale until a page refresh.
|
||||||
|
let famPayday = $derived(Number(page.data.fam?.payday ?? (famStore.fam as any)?.payday ?? 1));
|
||||||
|
let famTz = $derived(
|
||||||
|
resolveTz(page.data.fam?.timezone || (famStore.fam as any)?.timezone || 'auto')
|
||||||
|
);
|
||||||
|
let progressData = $derived.by((): BonusProgressResult[] => {
|
||||||
|
if (!famStore.initialized) return (data.progress as BonusProgressResult[]) || [];
|
||||||
|
const active = (famStore.bonusConfigs as BonusConfig[]).filter((c) => c.status === 'active');
|
||||||
|
return computeBonusProgress(
|
||||||
|
active,
|
||||||
|
famStore.members as Member[],
|
||||||
|
famStore.assigned as AssignedChore[],
|
||||||
|
famStore.completions as Completion[],
|
||||||
|
famStore.rewards as Reward[],
|
||||||
|
famPayday,
|
||||||
|
famTz
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
let showPeriod = $derived(editVals.occurrence !== 'once');
|
let showPeriod = $derived(editVals.occurrence !== 'once');
|
||||||
let showCriteria = $derived(editVals.type !== 'manual');
|
let showCriteria = $derived(editVals.type !== 'manual');
|
||||||
let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent');
|
let editIsPercent = $derived(
|
||||||
|
editVals.type === 'threshold' && editVals.thresholdType === 'percent'
|
||||||
|
);
|
||||||
// Standalone (no period) rewards can scope progress by completeBy + dates.
|
// Standalone (no period) rewards can scope progress by completeBy + dates.
|
||||||
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
|
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
|
||||||
|
|
||||||
@@ -151,7 +177,9 @@
|
|||||||
editVals.targetChoreIds = { ...editVals.targetChoreIds, [memberId]: value };
|
editVals.targetChoreIds = { ...editVals.targetChoreIds, [memberId]: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global && !t.isPocketMoney));
|
let templateConfigs = $derived(
|
||||||
|
(templates as BonusTemplate[]).filter((t) => t.global && !t.isPocketMoney)
|
||||||
|
);
|
||||||
let allBonusConfigs = $derived(
|
let allBonusConfigs = $derived(
|
||||||
configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[]
|
configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[]
|
||||||
);
|
);
|
||||||
@@ -162,7 +190,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let progressByConfigId = $derived.by(() => {
|
let progressByConfigId = $derived.by(() => {
|
||||||
const map = new Map<string, BonusConfigWithProgress>();
|
const map = new Map<string, BonusProgressResult>();
|
||||||
for (const p of progressData) map.set(p.config.id, p);
|
for (const p of progressData) map.set(p.config.id, p);
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
@@ -271,7 +299,12 @@
|
|||||||
const targetIds = (cfg.targetChoreIds as Record<string, string> | undefined) || {};
|
const targetIds = (cfg.targetChoreIds as Record<string, string> | undefined) || {};
|
||||||
// Legacy individual rewards pinned a chore via the scalar targetChoreId —
|
// Legacy individual rewards pinned a chore via the scalar targetChoreId —
|
||||||
// surface that in the per-member dropdown too.
|
// surface that in the per-member dropdown too.
|
||||||
if (cfg.target === 'individual' && cfg.memberId && cfg.targetChoreId && !targetIds[cfg.memberId]) {
|
if (
|
||||||
|
cfg.target === 'individual' &&
|
||||||
|
cfg.memberId &&
|
||||||
|
cfg.targetChoreId &&
|
||||||
|
!targetIds[cfg.memberId]
|
||||||
|
) {
|
||||||
targetIds[cfg.memberId] = cfg.targetChoreId;
|
targetIds[cfg.memberId] = cfg.targetChoreId;
|
||||||
}
|
}
|
||||||
editVals = {
|
editVals = {
|
||||||
@@ -318,12 +351,15 @@
|
|||||||
<strong>Pocket Money</strong>
|
<strong>Pocket Money</strong>
|
||||||
{#if hasUnsetPocket}
|
{#if hasUnsetPocket}
|
||||||
<p>
|
<p>
|
||||||
Every child has a <em>Pocket Money</em> droplet. Set how it's awarded — pick the
|
Every child has a <em>Pocket Money</em> droplet. Set how it's awarded — pick the amount (£)
|
||||||
amount (£) and the % of weekly chores they must complete to earn it — then save. Until
|
and the % of weekly chores they must complete to earn it — then save. Until you set the amount,
|
||||||
you set the amount, pocket money is paused for that child.
|
pocket money is paused for that child.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p>All Pocket Money droplets are set. Children will earn pocket money as they complete chores.</p>
|
<p>
|
||||||
|
All Pocket Money droplets are set. Children will earn pocket money as they complete
|
||||||
|
chores.
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -336,7 +372,12 @@
|
|||||||
<h3>Templates — Drag to assign</h3>
|
<h3>Templates — Drag to assign</h3>
|
||||||
<div class="template-list">
|
<div class="template-list">
|
||||||
{#each templateConfigs as cfg}
|
{#each templateConfigs as cfg}
|
||||||
<div class="template-card" draggable="true" ondragstart={(e) => handleDragStart(e, cfg)} style="border-left-color:{cfg.color}">
|
<div
|
||||||
|
class="template-card"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => handleDragStart(e, cfg)}
|
||||||
|
style="border-left-color:{cfg.color}"
|
||||||
|
>
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<TemplateIcon name={cfg.icon} size={18} color={cfg.color} />
|
<TemplateIcon name={cfg.icon} size={18} color={cfg.color} />
|
||||||
<strong>{cfg.name}</strong>
|
<strong>{cfg.name}</strong>
|
||||||
@@ -366,7 +407,12 @@
|
|||||||
<Card cols={3} scrollX>
|
<Card cols={3} scrollX>
|
||||||
<div class="kanban" class:rows={!targetsEnabled}>
|
<div class="kanban" class:rows={!targetsEnabled}>
|
||||||
<!-- Member Column -->
|
<!-- Member Column -->
|
||||||
<div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}>
|
<div
|
||||||
|
class="column member-col"
|
||||||
|
class:collapsed={collapsed.member}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondrop={handleDropToMember}
|
||||||
|
>
|
||||||
<button class="col-header" type="button" onclick={() => toggleCol('member')}>
|
<button class="col-header" type="button" onclick={() => toggleCol('member')}>
|
||||||
<span class="col-title">Member</span>
|
<span class="col-title">Member</span>
|
||||||
<span class="chev" class:open={!collapsed.member}>{@html chevronRight}</span>
|
<span class="chev" class:open={!collapsed.member}>{@html chevronRight}</span>
|
||||||
@@ -392,14 +438,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="reward-total {cfg.rewardType}">
|
<span class="reward-total {cfg.rewardType}">
|
||||||
{cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`}
|
{formatReward(cfg.rewardType, cfg.rewardValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
{#if !cfg.memberId || cfg.memberId === m.id}
|
{#if !cfg.memberId || cfg.memberId === m.id}
|
||||||
{@const p = progressByConfigId
|
{@const p = progressByConfigId
|
||||||
.get(cfg.id)
|
.get(cfg.id)
|
||||||
?.progress.find((pr: BonusProgress) => pr.memberId === m.id)}
|
?.progress.find((pr) => pr.memberId === m.id)}
|
||||||
{#if cfg.type === 'manual'}
|
{#if cfg.type === 'manual'}
|
||||||
<div class="member-progress">
|
<div class="member-progress">
|
||||||
<span class="mname" style="color:{m.color}">{m.name}</span>
|
<span class="mname" style="color:{m.color}">{m.name}</span>
|
||||||
@@ -414,8 +460,13 @@
|
|||||||
: criteria > 0
|
: criteria > 0
|
||||||
? Math.min(100, (current / criteria) * 100)
|
? Math.min(100, (current / criteria) * 100)
|
||||||
: 0}
|
: 0}
|
||||||
<div class="member-progress">
|
<div class="member-progress" class:done={achieved}>
|
||||||
<span class="mname" style="color:{m.color}">{m.name}</span>
|
<span class="mname" style="color:{m.color}">{m.name}</span>
|
||||||
|
{#if achieved}
|
||||||
|
<span class="done-chip">
|
||||||
|
<Check size={13} /> Done · {formatReward(cfg.rewardType, cfg.rewardValue)}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
{#if criteria > 0}
|
{#if criteria > 0}
|
||||||
<div class="bar-wrap">
|
<div class="bar-wrap">
|
||||||
<div class="bar-fill" style="width:{pct}%" class:met={achieved}></div>
|
<div class="bar-fill" style="width:{pct}%" class:met={achieved}></div>
|
||||||
@@ -423,12 +474,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if cfg.type === 'count'}
|
{#if cfg.type === 'count'}
|
||||||
<span class="count-progress">
|
<span class="count-progress">
|
||||||
{Math.min(current, criteria)} / {criteria}{hasTargetChore(cfg) ? ' chores' : ''}
|
{Math.min(current, criteria)} / {criteria}{hasTargetChore(cfg)
|
||||||
|
? ' chores'
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="stat {p?.state ?? 'pending'}">
|
<span class="stat {p?.state ?? 'pending'}">
|
||||||
{#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if}
|
{#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if}
|
||||||
</span>
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -457,7 +511,9 @@
|
|||||||
{#if !targetsEnabled}
|
{#if !targetsEnabled}
|
||||||
<span class="soon-badge">Coming soon</span>
|
<span class="soon-badge">Coming soon</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="chev" class:open={targetsEnabled ? !collapsed.comp : false}>{@html chevronRight}</span>
|
<span class="chev" class:open={targetsEnabled ? !collapsed.comp : false}
|
||||||
|
>{@html chevronRight}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
<div class="col-body">
|
<div class="col-body">
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
|
||||||
@@ -478,7 +534,7 @@
|
|||||||
<span class="badge">{cfg.occurrence}</span>
|
<span class="badge">{cfg.occurrence}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="reward-total {cfg.rewardType}">
|
<span class="reward-total {cfg.rewardType}">
|
||||||
{cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`}
|
{formatReward(cfg.rewardType, cfg.rewardValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if prog}
|
{#if prog}
|
||||||
@@ -532,7 +588,9 @@
|
|||||||
{#if !targetsEnabled}
|
{#if !targetsEnabled}
|
||||||
<span class="soon-badge">Coming soon</span>
|
<span class="soon-badge">Coming soon</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="chev" class:open={targetsEnabled ? !collapsed.collab : false}>{@html chevronRight}</span>
|
<span class="chev" class:open={targetsEnabled ? !collapsed.collab : false}
|
||||||
|
>{@html chevronRight}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
<div class="col-body">
|
<div class="col-body">
|
||||||
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
|
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
|
||||||
@@ -553,7 +611,7 @@
|
|||||||
<span class="badge">{cfg.occurrence}</span>
|
<span class="badge">{cfg.occurrence}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="reward-total {cfg.rewardType}">
|
<span class="reward-total {cfg.rewardType}">
|
||||||
{cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`}
|
{formatReward(cfg.rewardType, cfg.rewardValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if prog}
|
{#if prog}
|
||||||
@@ -620,20 +678,20 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="step-tab"
|
class="step-tab"
|
||||||
class:active={step === 1}
|
class:active={step === 1}
|
||||||
onclick={() => (step = 1)}
|
onclick={() => (step = 1)}>{@html stepIcon(1)}<span>Who</span></button
|
||||||
>{@html stepIcon(1)}<span>Who</span></button>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="step-tab"
|
class="step-tab"
|
||||||
class:active={step === 2}
|
class:active={step === 2}
|
||||||
onclick={() => (step = 2)}
|
onclick={() => (step = 2)}>{@html stepIcon(2)}<span>How</span></button
|
||||||
>{@html stepIcon(2)}<span>How</span></button>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="step-tab"
|
class="step-tab"
|
||||||
class:active={step === 3}
|
class:active={step === 3}
|
||||||
onclick={() => (step = 3)}
|
onclick={() => (step = 3)}>{@html stepIcon(3)}<span>What</span></button
|
||||||
>{@html stepIcon(3)}<span>What</span></button>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -645,7 +703,11 @@
|
|||||||
<input type="hidden" name="id" value={editingConfig?.id} />
|
<input type="hidden" name="id" value={editingConfig?.id} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if creatingFromTemplate}
|
{#if creatingFromTemplate}
|
||||||
<input type="hidden" name="isPocketMoney" value={creatingFromTemplate.isPocketMoney ? '1' : ''} />
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="isPocketMoney"
|
||||||
|
value={creatingFromTemplate.isPocketMoney ? '1' : ''}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- STEP 1 — Who (basics) -->
|
<!-- STEP 1 — Who (basics) -->
|
||||||
@@ -754,20 +816,19 @@
|
|||||||
value={editVals.targetChoreIds[editVals.memberId] || ''}
|
value={editVals.targetChoreIds[editVals.memberId] || ''}
|
||||||
onchange={(e) => editTargetChore(editVals.memberId!, e.currentTarget.value)}
|
onchange={(e) => editTargetChore(editVals.memberId!, e.currentTarget.value)}
|
||||||
>
|
>
|
||||||
<option value="">All of {m?.name || "this member"}’s chores</option>
|
<option value="">All of {m?.name || 'this member'}’s chores</option>
|
||||||
{#each chores as ac}
|
{#each chores as ac}
|
||||||
<option value={ac.id}>{choreName(ac)}</option>
|
<option value={ac.id}>{choreName(ac)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span class="hint"
|
<span class="hint"
|
||||||
>Count completions of a specific chore for {m?.name || 'this member'} only.
|
>Count completions of a specific chore for {m?.name || 'this member'} only. Leave
|
||||||
Leave as "All..." to count every chore they’re assigned.</span
|
as "All..." to count every chore they’re assigned.</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="hint member-target-hint">
|
<p class="hint member-target-hint">
|
||||||
Pick a target chore per member — each child counts their own assigned
|
Pick a target chore per member — each child counts their own assigned chores.
|
||||||
chores.
|
|
||||||
</p>
|
</p>
|
||||||
{#each members as m}
|
{#each members as m}
|
||||||
{@const chores = choresForMember(m.id)}
|
{@const chores = choresForMember(m.id)}
|
||||||
@@ -801,8 +862,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span class="hint"
|
<span class="hint"
|
||||||
>Count completions of a specific chore only. Leave as "All Chores" to
|
>Count completions of a specific chore only. Leave as "All Chores" to count
|
||||||
count every assigned chore.</span
|
every assigned chore.</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -826,8 +887,8 @@
|
|||||||
<option value="monthly">Monthly</option>
|
<option value="monthly">Monthly</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="hint"
|
<span class="hint"
|
||||||
>Recurring bonuses reset and re-earn each period. Leave as "None" for a
|
>Recurring bonuses reset and re-earn each period. Leave as "None" for a one-off
|
||||||
one-off reward measured once.</span
|
reward measured once.</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -835,7 +896,8 @@
|
|||||||
<div class="start-mode">
|
<div class="start-mode">
|
||||||
<label
|
<label
|
||||||
><span class="field-label"><CalendarDays size={14} /> Progress counts</span
|
><span class="field-label"><CalendarDays size={14} /> Progress counts</span
|
||||||
></label>
|
></label
|
||||||
|
>
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@@ -859,15 +921,17 @@
|
|||||||
{#if editVals.completeBy === 'custom'}
|
{#if editVals.completeBy === 'custom'}
|
||||||
<label>
|
<label>
|
||||||
<span class="field-label"><CalendarDays size={14} /> Complete by</span>
|
<span class="field-label"><CalendarDays size={14} /> Complete by</span>
|
||||||
<input type="date" name="completeByDate" bind:value={editVals.completeByDate} />
|
<input
|
||||||
|
type="date"
|
||||||
|
name="completeByDate"
|
||||||
|
bind:value={editVals.completeByDate}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<label>
|
<label>
|
||||||
<span class="field-label"><CalendarDays size={14} /> Progress starts on</span>
|
<span class="field-label"><CalendarDays size={14} /> Progress starts on</span>
|
||||||
<input type="date" name="startDate" bind:value={editVals.startDate} />
|
<input type="date" name="startDate" bind:value={editVals.startDate} />
|
||||||
<span class="hint"
|
<span class="hint">Points before this date won't count toward the reward.</span>
|
||||||
>Points before this date won't count toward the reward.</span
|
|
||||||
>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -916,7 +980,8 @@
|
|||||||
<div class="start-mode">
|
<div class="start-mode">
|
||||||
<label
|
<label
|
||||||
><span class="field-label"><CalendarDays size={14} /> When to start</span
|
><span class="field-label"><CalendarDays size={14} /> When to start</span
|
||||||
></label>
|
></label
|
||||||
|
>
|
||||||
<div class="radio-group">
|
<div class="radio-group">
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@@ -1016,7 +1081,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit" class="save-btn">
|
<button type="submit" class="save-btn">
|
||||||
<Save size={14} /> {creatingFromTemplate ? 'Assign' : 'Save'}
|
<Save size={14} />
|
||||||
|
{creatingFromTemplate ? 'Assign' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button type="button" class="next-btn" onclick={() => (step = 2)}>
|
<button type="button" class="next-btn" onclick={() => (step = 2)}>
|
||||||
@@ -1090,9 +1156,18 @@
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
background: #fafbff;
|
background: #fafbff;
|
||||||
}
|
}
|
||||||
.member-col { border-top-color: #6366f1; background: #eef2ff; }
|
.member-col {
|
||||||
.comp-col { border-top-color: #f59e0b; background: #fffbeb; }
|
border-top-color: #6366f1;
|
||||||
.collab-col { border-top-color: #10b981; background: #ecfdf5; }
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
.comp-col {
|
||||||
|
border-top-color: #f59e0b;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
.collab-col {
|
||||||
|
border-top-color: #10b981;
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
.column h3 {
|
.column h3 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@@ -1240,7 +1315,10 @@
|
|||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
transition:
|
||||||
|
transform 0.15s,
|
||||||
|
box-shadow 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
}
|
}
|
||||||
.col-card:hover {
|
.col-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -1296,8 +1374,26 @@
|
|||||||
.member-progress {
|
.member-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.3rem;
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.member-progress.done {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
}
|
||||||
|
.done-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #047857;
|
||||||
|
background: #d1fae5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.mname {
|
.mname {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST({ request, url }) {
|
|||||||
const resetUrl = `${url.origin}/login/verify/${token}`;
|
const resetUrl = `${url.origin}/login/verify/${token}`;
|
||||||
|
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
from: 'no-reply@famchamp.ai',
|
from: 'no-reply@walthamstow.xyz',
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Reset your FamDone password',
|
subject: 'Reset your FamDone password',
|
||||||
html: `
|
html: `
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Bonus progress computation — single source of truth shared by the server
|
||||||
|
// rewards loader (`services/bonuses.ts` → `progress()`) and the Rewards page
|
||||||
|
// client-side realtime recompute (famStore + PB SSE). Both views must show the
|
||||||
|
// same counts, so the algorithm lives here rather than in two places.
|
||||||
|
import { bonusWindow, completionInWindow } from "./timezone";
|
||||||
|
|
||||||
|
export interface BonusProgressConfig {
|
||||||
|
id: string;
|
||||||
|
target: string;
|
||||||
|
memberId?: string;
|
||||||
|
type: string;
|
||||||
|
thresholdType?: string;
|
||||||
|
criteriaValue?: number;
|
||||||
|
period?: string;
|
||||||
|
targetChoreId?: string;
|
||||||
|
targetChoreIds?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusProgressMember {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusProgressAssigned {
|
||||||
|
id: string;
|
||||||
|
memberId?: string | null;
|
||||||
|
frequency?: string;
|
||||||
|
type?: string;
|
||||||
|
value?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusProgressCompletion {
|
||||||
|
memberId: string;
|
||||||
|
assignedChoreId: string;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusProgressReward {
|
||||||
|
id: string;
|
||||||
|
memberId: string;
|
||||||
|
bonusConfigId?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusProgressRow {
|
||||||
|
memberId: string;
|
||||||
|
memberName: string;
|
||||||
|
memberColor: string;
|
||||||
|
current: number;
|
||||||
|
criteriaValue: number;
|
||||||
|
reward: { id: string; status: string } | null;
|
||||||
|
state: string;
|
||||||
|
achieved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BonusConfigWithProgress {
|
||||||
|
config: BonusProgressConfig;
|
||||||
|
progress: BonusProgressRow[];
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-member target-chore pick wins over the legacy scalar targetChoreId.
|
||||||
|
function targetChoreFor(
|
||||||
|
cfg: BonusProgressConfig,
|
||||||
|
memberId: string,
|
||||||
|
): string | undefined {
|
||||||
|
if (
|
||||||
|
cfg.targetChoreIds &&
|
||||||
|
typeof cfg.targetChoreIds === "object" &&
|
||||||
|
cfg.targetChoreIds[memberId]
|
||||||
|
) {
|
||||||
|
return cfg.targetChoreIds[memberId];
|
||||||
|
}
|
||||||
|
return cfg.targetChoreId || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute per-config progress rows for every member (or the team/winner for
|
||||||
|
// collaborative/competitive targets). Callers pass the configs they want
|
||||||
|
// scored (the server uses active-only; the Rewards page mirrors that).
|
||||||
|
export function computeBonusProgress(
|
||||||
|
configs: BonusProgressConfig[],
|
||||||
|
members: BonusProgressMember[],
|
||||||
|
assigned: BonusProgressAssigned[],
|
||||||
|
completions: BonusProgressCompletion[],
|
||||||
|
rewards: BonusProgressReward[],
|
||||||
|
payday: number,
|
||||||
|
tz: string,
|
||||||
|
): BonusConfigWithProgress[] {
|
||||||
|
// Chores "due" per week per member (daily = 7, otherwise 1). Used by the
|
||||||
|
// `percent` threshold type to compute % of chores completed.
|
||||||
|
const dueByMember: Record<string, number> = {};
|
||||||
|
for (const a of assigned) {
|
||||||
|
if (a.memberId) {
|
||||||
|
dueByMember[a.memberId] =
|
||||||
|
(dueByMember[a.memberId] || 0) + (a.frequency === "daily" ? 7 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
||||||
|
|
||||||
|
const result: BonusConfigWithProgress[] = [];
|
||||||
|
|
||||||
|
for (const cfg of configs) {
|
||||||
|
const cfgRewards = rewards.filter((r) => r.bonusConfigId === cfg.id);
|
||||||
|
const win = bonusWindow(cfg, payday, tz);
|
||||||
|
const pStart = win.from;
|
||||||
|
const pEnd = win.to;
|
||||||
|
const periodCompletions = completions.filter((c) =>
|
||||||
|
completionInWindow(c, win),
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressRows: BonusProgressRow[] = [];
|
||||||
|
|
||||||
|
if (cfg.target === "collaborative") {
|
||||||
|
let teamCompletions = periodCompletions.filter((c) =>
|
||||||
|
members.some((m) => m.id === c.memberId),
|
||||||
|
);
|
||||||
|
if (cfg.targetChoreId) {
|
||||||
|
teamCompletions = teamCompletions.filter(
|
||||||
|
(c) => c.assignedChoreId === cfg.targetChoreId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let teamCurrent = 0;
|
||||||
|
if (cfg.type === "threshold") {
|
||||||
|
if (cfg.thresholdType === "percent") {
|
||||||
|
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
|
||||||
|
} else {
|
||||||
|
teamCurrent = teamCompletions.reduce((sum, c) => {
|
||||||
|
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === "points" ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} else if (cfg.type === "count") {
|
||||||
|
teamCurrent = teamCompletions.length;
|
||||||
|
}
|
||||||
|
const teamReward = cfgRewards[0];
|
||||||
|
progressRows.push({
|
||||||
|
memberId: "__team__",
|
||||||
|
memberName: "Team Total",
|
||||||
|
memberColor: "#8b5cf6",
|
||||||
|
current: teamCurrent,
|
||||||
|
criteriaValue: cfg.criteriaValue || 0,
|
||||||
|
reward: teamReward
|
||||||
|
? { id: teamReward.id, status: teamReward.status }
|
||||||
|
: null,
|
||||||
|
state: teamReward ? teamReward.status : "pending",
|
||||||
|
achieved: teamReward ? true : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.target !== "collaborative") {
|
||||||
|
const progressMembers =
|
||||||
|
cfg.target === "individual" && cfg.memberId
|
||||||
|
? members.filter((m) => m.id === cfg.memberId)
|
||||||
|
: members;
|
||||||
|
for (const m of progressMembers) {
|
||||||
|
const memberTarget = targetChoreFor(cfg, m.id);
|
||||||
|
let memberCompletions = periodCompletions.filter(
|
||||||
|
(c) => c.memberId === m.id,
|
||||||
|
);
|
||||||
|
if (memberTarget) {
|
||||||
|
memberCompletions = memberCompletions.filter(
|
||||||
|
(c) => c.assignedChoreId === memberTarget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const memberReward = cfgRewards.find((r) => r.memberId === m.id);
|
||||||
|
|
||||||
|
let current = 0;
|
||||||
|
if (cfg.type === "threshold") {
|
||||||
|
if (cfg.thresholdType === "percent") {
|
||||||
|
const due = dueByMember[m.id] || 0;
|
||||||
|
current =
|
||||||
|
due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
|
||||||
|
} else {
|
||||||
|
current = memberCompletions.reduce((sum, c) => {
|
||||||
|
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||||||
|
return sum + (chore?.type === "points" ? Number(chore.value) : 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} else if (cfg.type === "count") {
|
||||||
|
current = memberCompletions.length;
|
||||||
|
} else if (cfg.type === "manual") {
|
||||||
|
current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressRows.push({
|
||||||
|
memberId: m.id,
|
||||||
|
memberName: m.name,
|
||||||
|
memberColor: m.color,
|
||||||
|
current,
|
||||||
|
criteriaValue: cfg.criteriaValue || 0,
|
||||||
|
reward: memberReward
|
||||||
|
? { id: memberReward.id, status: memberReward.status }
|
||||||
|
: null,
|
||||||
|
state: memberReward ? memberReward.status : "pending",
|
||||||
|
achieved: memberReward ? true : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
config: cfg,
|
||||||
|
progress: progressRows,
|
||||||
|
periodStart: pStart,
|
||||||
|
periodEnd: pEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user