Compare commits

...

3 Commits

Author SHA1 Message Date
JCEEE e441c3d2a7 1.10.7 2026-09-14 12:12:11 +01:00
JCEEE 158a60f30c add toggle fix and possible log out issue 2026-09-14 12:09:44 +01:00
JCEEE 70a38ee95b update homepage text 2026-09-11 18:20:18 +01:00
10 changed files with 313 additions and 147 deletions
+4
View File
@@ -64,3 +64,7 @@ export const xCircleIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>'; '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>';
export const infoIcon = export const infoIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>'; '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>';
export const shieldIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 11.5 2 2 4-4"/></svg>';
export const laptopIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9"/><path d="M4 16H2.9a1 1 0 0 0-.95 1.33l.6 1.5a1 1 0 0 0 .95.67h17a1 1 0 0 0 .95-.67l.6-1.5A1 1 0 0 0 21.1 16H20"/></svg>';
+18 -1
View File
@@ -10,6 +10,23 @@ import {
} from '@shared/timezone'; } from '@shared/timezone';
import { computeBonusProgress } from '@shared/bonus-progress'; import { computeBonusProgress } from '@shared/bonus-progress';
import { famMeta } from './fam'; import { famMeta } from './fam';
import { pbAdmin } from '$lib/server/pocketbase';
// `fams` reads via superuser: the acting token may predate RULE_OWN_FAM
// (migrate only bootstraps fresh stores), which otherwise logs a 404 and
// aborts evaluation. Fam-scoped by record id.
async function famMetaSU(famId: string) {
try {
const fam: any = await pbAdmin.getOne('fams', famId);
return {
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
paydayTime: fam.paydayTime || '18:00',
tz: resolveTz(fam.timezone || 'auto')
};
} catch {
return { payday: 1, paydayTime: '18:00', tz: resolveTz('auto') };
}
}
function resolveServerTz(tz?: string): string { function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto'); return resolveTz(tz || 'auto');
@@ -64,7 +81,7 @@ export async function evaluateFam(pb: any, famId: string) {
try { try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {} } catch {}
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId); const { payday: paydayEval, tz: tzEval } = await famMetaSU(famId);
// Chores "due" this week per member (daily = 7, otherwise 1). Used by the // Chores "due" this week per member (daily = 7, otherwise 1). Used by the
// `percent` threshold type to compute % of chores completed. // `percent` threshold type to compute % of chores completed.
@@ -1,7 +1,27 @@
import { periodWindow } from '@shared/timezone'; import { periodWindow } from '@shared/timezone';
import { famMeta } from './fam'; import { pbAdmin } from '$lib/server/pocketbase';
import { evaluateFam } from './bonuses'; import { evaluateFam } from './bonuses';
// Reads/writes the acting user may not be permitted by PB rules:
// - `fams` view (prod may predate RULE_OWN_FAM; migrate only bootstraps fresh)
// - `assigned_chores` update (parent-only rule, but children claim shared chores)
// Both stay fam-scoped: the fam record id and the chore's own famId are checked.
async function famMetaSU(famId: string) {
const fam: any = await pbAdmin.getOne('fams', famId);
const { resolveTz } = await import('@shared/timezone');
return {
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
paydayTime: fam.paydayTime || '18:00',
tz: resolveTz(fam.timezone || 'auto')
};
}
async function claimChore(assignedChoreId: string, famId: string, memberId: string | '') {
const chore: any = await pbAdmin.getOne('assigned_chores', assignedChoreId);
if (!chore || chore.famId !== famId) throw new Error('Chore not found');
await pbAdmin.update('assigned_chores', assignedChoreId, { memberId });
}
export async function myChores(pb: any, famId: string, memberId: string) { export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([ const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }), pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
@@ -12,7 +32,7 @@ export async function myChores(pb: any, famId: string, memberId: string) {
pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }), pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` }) pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}' && status = 'active'` })
]); ]);
const { payday, paydayTime, tz } = await famMeta(pb, famId); const { payday, paydayTime, tz } = await famMetaSU(famId);
return { return {
templates, templates,
assigned, assigned,
@@ -39,13 +59,13 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
let filter: string; let filter: string;
if (isShared && !isTodo) { if (isShared && !isTodo) {
// Shared non-todo: any completion in the period = already claimed // Shared non-todo: any completion in the period = already claimed
const { payday, tz } = await famMeta(pb, famId); const { payday, tz } = await famMetaSU(famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz); const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && date >= '${from}' && date < '${to}'`; filter = `assignedChoreId = '${assignedChoreId}' && date >= '${from}' && date < '${to}'`;
} else if (isTodo) { } else if (isTodo) {
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`; filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
} else { } else {
const { payday, tz } = await famMeta(pb, famId); const { payday, tz } = await famMetaSU(famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz); const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`; filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
} }
@@ -60,7 +80,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
} }
// If this was a shared chore claimed by this member, un-share it // If this was a shared chore claimed by this member, un-share it
if (isShared && existing[0].memberId === memberId) { if (isShared && existing[0].memberId === memberId) {
await pb.collection('assigned_chores').update(assignedChoreId, { memberId: '' }); await claimChore(assignedChoreId, famId, '');
} }
evaluateFam(pb, famId).catch(() => {}); evaluateFam(pb, famId).catch(() => {});
return { completed: false }; return { completed: false };
@@ -89,7 +109,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
}); });
// Claim the shared chore by setting memberId // Claim the shared chore by setting memberId
if (isShared) { if (isShared) {
await pb.collection('assigned_chores').update(assignedChoreId, { memberId }); await claimChore(assignedChoreId, famId, memberId);
} }
evaluateFam(pb, famId).catch(() => {}); evaluateFam(pb, famId).catch(() => {});
return { completed: true, record }; return { completed: true, record };
@@ -107,7 +127,7 @@ export async function revoke(pb: any, famId: string, completionId: string) {
.getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` }); .getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` });
const chore = choreList?.[0]; const chore = choreList?.[0];
if (chore?.shared === true) { if (chore?.shared === true) {
await pb.collection('assigned_chores').update(chore.id, { memberId: '' }); await claimChore(chore.id, famId, '');
} }
evaluateFam(pb, famId).catch(() => {}); evaluateFam(pb, famId).catch(() => {});
return { revoked: true }; return { revoked: true };
+11 -1
View File
@@ -15,7 +15,17 @@ export function readShortcut(): Shortcut | null {
if (typeof localStorage === 'undefined') return null; if (typeof localStorage === 'undefined') return null;
try { try {
const raw = localStorage.getItem(KEY); const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Shortcut) : null; if (!raw) return null;
const s = JSON.parse(raw) as Shortcut;
// Self-heal the old poisoned entry: a write that fell back to
// page.params.fam while page.data.fam was null stored the PB id as
// both slug and name ({famSlug: id, famName: id}). A real slug is
// never identical to the display name AND a 15-char PB id.
if (s && s.famSlug === s.famName && /^[a-z0-9]{15}$/.test(s.famSlug || '')) {
localStorage.removeItem(KEY);
return null;
}
return s;
} catch { } catch {
return null; return null;
} }
+186 -91
View File
@@ -1,7 +1,19 @@
<script lang="ts"> <script lang="ts">
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import FamDone from '$lib/components/FamDone.svelte'; import FamDone from '$lib/components/FamDone.svelte';
import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons'; import {
homeIcon,
choresIcon,
checkCircleIcon,
chevronRightIcon,
dashboardIcon,
chatIcon,
gemIcon,
lockIcon,
coinsIcon,
shieldIcon,
laptopIcon
} from '$lib/components/icons';
import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut'; import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut';
import CheckboxGrid from '$lib/components/CheckboxGrid.svelte'; import CheckboxGrid from '$lib/components/CheckboxGrid.svelte';
@@ -40,7 +52,7 @@
<div class="hero-content"> <div class="hero-content">
<span class="hero-kicker">The family chore app</span> <span class="hero-kicker">The family chore app</span>
<h1 class="hero-title"><FamDone /></h1> <h1 class="hero-title"><FamDone /></h1>
<p class="hero-sub">Every chore checked. Every allowance earned — on autopilot.</p> <p class="hero-sub">Building <span class="text-green-400">healthy habbits</span> together!</p>
<div class="hero-actions"> <div class="hero-actions">
<a class="hero-cta" href="/signup">Create my family</a> <a class="hero-cta" href="/signup">Create my family</a>
<a class="hero-cta-ghost" href="/pricing">See pricing</a> <a class="hero-cta-ghost" href="/pricing">See pricing</a>
@@ -51,20 +63,25 @@
<div class="intro-inner"> <div class="intro-inner">
<div class="intro-copy"> <div class="intro-copy">
<span class="pill">For busy families</span> <span class="pill">For busy families</span>
<h1>Chores done. <span class="accent">Allowance earned.</span></h1> <h1>Chores done. <span class="accent">Get rewarded.</span></h1>
<p class="lede"> <p class="lede">
<FamDone /> turns everyday household chores into points and pocket money. Assign the chores, <FamDone /> lets you choose chores and rewards with your kids.
let your kids see their progress live, and <FamDone /> calculates the allowance automatically
— no spreadsheets, no nagging.
</p> </p>
<p class="lede">
Because life is fun, <FamDone /> makes it easy for the tricky things become part of your daily
routine.
</p>
<!-- <p> - letting them become
empowered and take ownership of the things they forget about - You might learn a thing or two too!</p> -->
<ul class="hero-ticks"> <ul class="hero-ticks">
<li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li> <li><span class="tick">{@html checkCircleIcon}</span>Set up in under a minute</li>
<li> <li>
<span class="tick">{@html checkCircleIcon}</span>Kids join with a simple invite code <span class="tick">{@html checkCircleIcon}</span>Kids join with their own device or
family computer
</li> </li>
<li> <li>
<span class="tick">{@html checkCircleIcon}</span>Allowance lands on payday, <span class="tick">{@html checkCircleIcon}</span>Get a free trial and see if it's right
automatically for you
</li> </li>
</ul> </ul>
</div> </div>
@@ -73,11 +90,6 @@
<h2>Start your family</h2> <h2>Start your family</h2>
<p class="card-sub">Free to get going. Takes about a minute.</p> <p class="card-sub">Free to get going. Takes about a minute.</p>
<a class="submit" href="/signup">Create my family</a> <a class="submit" href="/signup">Create my family</a>
<p class="card-alt">
<a class="flex gap-2 self-center align-center justify-center" href="/pricing"
>See pricing {@html chevronRightIcon}</a
>
</p>
<p class="card-alt"> <p class="card-alt">
Already have a family? <a href="/login">Log in</a> Already have a family? <a href="/login">Log in</a>
</p> </p>
@@ -86,116 +98,108 @@
<div class="explainer"> <div class="explainer">
<div class="steps"> <div class="steps">
<article class="step"> <article class="step">
<div class="step-head">
<span class="step-media">
<span class="step-icon">{@html homeIcon}</span>
<span class="step-num">1</span> <span class="step-num">1</span>
</span>
<h3>Create your family</h3> <h3>Create your family</h3>
</div>
<p> <p>
Set up chores and how much each one is worth. Your kids join in seconds with an invite Setting up chores isn't a bore! Your kids join in seconds with an <strong
code. >emailess</strong
> invite code.
</p> </p>
</article> </article>
<article class="step"> <article class="step">
<div class="step-head">
<span class="step-media">
<span class="step-icon">{@html choresIcon}</span>
<span class="step-num">2</span> <span class="step-num">2</span>
</span>
<h3>Kids do the work</h3> <h3>Kids do the work</h3>
</div>
<p> <p>
They see today's chores as a simple card board and tick them off as they go — points are They see today's chores as a simple card board and tick them off as they go — points are
added instantly. added instantly.
</p> </p>
</article> </article>
<article class="step"> <article class="step">
<div class="step-head">
<span class="step-media">
<span class="step-icon">{@html coinsIcon}</span>
<span class="step-num">3</span> <span class="step-num">3</span>
<h3>Allowance pays out</h3> </span>
<h3>Payday!</h3>
</div>
<p> <p>
Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep If a kid meets their criteria - they can claim their pocket money by annoying you like a
it fun. noisey flying unicorn.
</p> </p>
</article> </article>
</div> </div>
</div> </div>
</section> </section>
<!-- EXPLAINER -->
<!-- <section class="explainer">
<div class="steps">
<article class="step">
<span class="step-num">1</span>
<h3>Create your family</h3>
<p>
Set up chores and how much each one is worth. Your kids join in seconds with an invite
code.
</p>
</article>
<article class="step">
<span class="step-num">2</span>
<h3>Kids do the work</h3>
<p>
They see today's chores as a simple card board and tick them off as they go — points are
added instantly.
</p>
</article>
<article class="step">
<span class="step-num">3</span>
<h3>Allowance pays out</h3>
<p>
Points add up, then turn into pocket money on payday. Rewards and monthly bonuses keep it
fun.
</p>
</article>
</div>
</section> -->
<!-- FEATURES -->
<section class="features"> <section class="features">
<div class="features-inner"> <div class="features-inner">
<h2>Everything a family needs</h2> <h2>The BIG Idea</h2>
<div class="feature-grid"> <div class="feature-grid">
<article class="feature"> <article class="feature" style="--ic-bg:#e0e7ff; --ic-fg:#4338ca">
<div class="feature-head">
<span class="feature-icon">{@html dashboardIcon}</span>
<h3>Chore board</h3> <h3>Chore board</h3>
</div>
<p>Simple and intuitive chore board for parents to assign tasks to their kids.</p>
</article>
<article class="feature" style="--ic-bg:#ccfbf1; --ic-fg:#0d9488">
<div class="feature-head">
<span class="feature-icon">{@html chatIcon}</span>
<h3>Chat</h3>
</div>
<p> <p>
Reusable chore templates and a simple assignment grid. Build once, reuse every week. Real-time chat for parents to communicate with their kids - so you can congratulate
them!
</p> </p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#ede9fe; --ic-fg:#7c3aed">
<h3>Live progress</h3> <div class="feature-head">
<p> <span class="feature-icon">{@html shieldIcon}</span>
Realtime sync across the whole family — see points and progress update the moment a <h3>Trust!</h3>
chore is done. </div>
</p> <p>The whole experience is about exercising trust together.</p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#fef3c7; --ic-fg:#d97706">
<h3>Automatic allowance</h3> <div class="feature-head">
<p> <span class="feature-icon">{@html gemIcon}</span>
Points convert to money using your family's own rules. No mental maths, no arguments.
</p>
</article>
<article class="feature">
<h3>Rewards &amp; bonuses</h3> <h3>Rewards &amp; bonuses</h3>
</div>
<p> <p>
Kids can spend points on rewards, and you can run a monthly bonus for the top earner. As a parent you can dish out behaviour rewards or pocket money boosters - in a place
which is yours to create together.
</p> </p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#e0f2fe; --ic-fg:#0284c7">
<h3>Kid-friendly</h3> <div class="feature-head">
<p> <span class="feature-icon">{@html laptopIcon}</span>
A simple, colourful interface kids love — big buttons, clear feedback, their own space. <h3>Phone free friendly</h3>
</p> </div>
<p>You dont need a mobile or an app - just a browser.</p>
</article> </article>
<article class="feature"> <article class="feature" style="--ic-bg:#e2e8f0; --ic-fg:#475569">
<div class="feature-head">
<span class="feature-icon">{@html lockIcon}</span>
<h3>Private by design</h3> <h3>Private by design</h3>
</div>
<p> <p>
Everything is scoped to your family. Kids join with an invite code and stay in your We don't read your data, your messages, or sell anything - your family and rules is your
family. property.
</p> </p>
</article> </article>
</div> </div>
<div class="cta"> <div class="cta">
<p>Ready to make chores painless?</p> <p>Ready to get the kids involved?</p>
<a class="cta-btn" href="/signup">Create your family</a> <a class="cta-btn" href="/signup">Create your family</a>
<p class="card-alt">
<a
class="hover:underline self-center items-center justify-center flex gap-2"
href="/pricing">or see pricing {@html chevronRightIcon}</a
>
</p>
</div> </div>
</div> </div>
</section> </section>
@@ -555,22 +559,72 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 14px; border-radius: 14px;
padding: 1.5rem; padding: 1.5rem;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
} }
.step-num { .step:hover {
transform: translateY(-3px);
border-color: #c7d2fe;
box-shadow: 0 12px 24px rgba(67, 56, 202, 0.1);
}
.step-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.step-media {
position: relative;
display: inline-block;
flex-shrink: 0;
}
.step-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2rem; width: 3rem;
height: 2rem; height: 3rem;
border-radius: 50%; border-radius: 12px;
background: #4338ca; background: linear-gradient(135deg, #4338ca, #6d28d9);
color: #fff; color: #fff;
font-weight: 700; box-shadow: 0 6px 14px rgba(67, 56, 202, 0.25);
margin-bottom: 0.75rem; }
.step-icon :global(svg) {
width: 1.5em;
height: 1.5em;
}
.step-num {
position: absolute;
top: -0.4rem;
right: -0.4rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
background: #fbbf24;
color: #1e1b4b;
font-weight: 800;
font-size: 0.72rem;
border: 2px solid #fff;
} }
.step h3 { .step h3 {
margin: 0 0 0.5rem; margin: 0;
font-size: 1.05rem; font-size: 1.05rem;
text-align: right;
}
.step h3::after {
content: '';
display: block;
width: 100%;
height: 3px;
border-radius: 2px;
background: #4338ca;
margin-top: 0.45rem;
} }
.step p { .step p {
margin: 0; margin: 0;
@@ -596,11 +650,52 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 12px; border-radius: 12px;
padding: 1.25rem; padding: 1.25rem;
transition:
transform 0.15s,
box-shadow 0.15s,
border-color 0.15s;
}
.feature:hover {
transform: translateY(-3px);
border-color: #d1d5db;
box-shadow: 0 14px 28px rgba(67, 56, 202, 0.12);
}
.feature-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.6rem;
height: 2.6rem;
border-radius: 10px;
background: var(--ic-bg);
color: var(--ic-fg);
}
.feature-icon :global(svg) {
width: 1.35em;
height: 1.35em;
} }
.feature h3 { .feature h3 {
margin: 0 0 0.4rem; margin: 0;
font-size: 1rem; font-size: 1rem;
color: #111827; color: #111827;
text-align: right;
}
.feature h3::after {
content: '';
display: block;
width: 100%;
height: 3px;
border-radius: 2px;
background: var(--ic-fg);
margin-top: 0.45rem;
} }
.feature p { .feature p {
margin: 0; margin: 0;
+7 -2
View File
@@ -134,11 +134,16 @@
// their OTP on /{fam}/join/{username} then landing on /{fam}/{username}. // their OTP on /{fam}/join/{username} then landing on /{fam}/{username}.
// The handle comes from the session (not page.params) so jump-back-in lands // The handle comes from the session (not page.params) so jump-back-in lands
// on the user's own dashboard even from the bare /{fam} page. // on the user's own dashboard even from the bare /{fam} page.
// The slug MUST come from the fam record — page.params.fam may be the PB
// id (login ID-fallback / stale link) when page.data.fam failed to load,
// and recording that poisons the shortcut (famSlug === famName === id).
$effect(() => { $effect(() => {
if (!page.data.session) return; if (!page.data.session) return;
const slug = page.data.fam?.slug as string;
if (!slug) return;
recordShortcut({ recordShortcut({
famSlug: (page.data.fam?.slug as string) || page.params.fam || '', famSlug: slug,
famName: (page.data.fam?.name as string) || page.params.fam || '', famName: (page.data.fam?.name as string) || slug,
userName: page.data.session.username || page.params.username || '' userName: page.data.session.username || page.params.username || ''
}); });
}); });
+18 -13
View File
@@ -160,19 +160,24 @@
.filter((a: any) => a.memberId === memberId && a.frequency === 'daily') .filter((a: any) => a.memberId === memberId && a.frequency === 'daily')
.reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0); .reduce((sum: number, a: any) => sum + (a.type === 'points' ? Number(a.value) || 0 : 0), 0);
} }
// Days elapsed in the current week (min 1), for a running daily average. // All-time average daily points for a member, from the server-computed
function daysElapsed(): number { // weekly series (all history, not just this week). Days active runs from
const days = summary?.daysInWeek || []; // their earliest recorded week through today (min 1).
function avgDailyPointsFor(memberId: string): number {
const line = trend.find((m: any) => m.memberId === memberId);
const weeks = line?.weeks || [];
if (weeks.length === 0) return 0;
const total = weeks.reduce((sum: number, w: any) => sum + (Number(w.points) || 0), 0);
const earliest = weeks.map((w: any) => w.weekStart).sort()[0];
const today = todayInTz(famTz); const today = todayInTz(famTz);
return Math.max(1, days.filter((d: string) => d <= today).length); const days = Math.max(
} 1,
// Average daily points from chore completions this week. Math.round(
function avgDailyPointsFor(s: any): number { (new Date(today + 'T00:00:00').getTime() - new Date(earliest + 'T00:00:00').getTime()) /
const total = Object.values(s.dayPoints || {}).reduce( 86400000
(sum: number, v: any) => sum + (Number(v) || 0), ) + 1
0
); );
return total / daysElapsed(); return total / days;
} }
// Today's completion count — tolerant of UTC vs fam-local date keys. // Today's completion count — tolerant of UTC vs fam-local date keys.
function todayDone(s: any): number { function todayDone(s: any): number {
@@ -257,7 +262,7 @@
if (scoreCanvas) { if (scoreCanvas) {
donutCharts[`${s.memberId}:score`] = makeDonut( donutCharts[`${s.memberId}:score`] = makeDonut(
scoreCanvas, scoreCanvas,
avgDailyPointsFor(s), avgDailyPointsFor(s.memberId),
dailyPointsPotential(s.memberId), dailyPointsPotential(s.memberId),
s.memberColor s.memberColor
); );
@@ -379,7 +384,7 @@
{@const tdDone = todayDone(s)} {@const tdDone = todayDone(s)}
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0} {@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
{@const scPot = dailyPointsPotential(s.memberId)} {@const scPot = dailyPointsPotential(s.memberId)}
{@const scVal = avgDailyPointsFor(s)} {@const scVal = avgDailyPointsFor(s.memberId)}
{@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)} {@const goals = goalsByMember.get(s.memberId)}
<div class="column col-member" style="--mc: {s.memberColor}"> <div class="column col-member" style="--mc: {s.memberColor}">
@@ -174,13 +174,14 @@
}); });
} }
// Goals completed feed for the admin overview — every materialized bonus // Goals completed feed for the admin overview — this week's materialized
// reward (prize / points / cash), newest first. Undoing the underlying // bonus rewards (prize / points / cash), newest first. Undoing the
// chore/points deletes the unclaimed reward server-side, so this list // underlying chore/points deletes the unclaimed reward server-side, so
// shrinks again in realtime via famStore. // this list shrinks again in realtime via famStore.
let parentGoalsCompleted = $derived.by(() => let parentGoalsCompleted = $derived.by(() => {
parentRewards const ws = (summary?.weekStart as string) || today;
.filter((r: any) => r.bonusConfigId) return parentRewards
.filter((r: any) => r.bonusConfigId && (r.date || '').slice(0, 10) >= ws)
.map((r: any) => { .map((r: any) => {
const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId); const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId);
const m = parentMembers.find((x: any) => x.id === r.memberId); const m = parentMembers.find((x: any) => x.id === r.memberId);
@@ -199,8 +200,8 @@
}; };
}) })
.sort((a: any, b: any) => (b.date || '').localeCompare(a.date || '')) .sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
.slice(0, 10) .slice(0, 10);
); });
// ─── Child View (kanban) ─── // ─── Child View (kanban) ───
let memberId = $state(data.memberId || ''); let memberId = $state(data.memberId || '');
@@ -242,7 +243,7 @@
let claimError = $state(''); let claimError = $state('');
let todayChild = $derived(todayInTz(famTz)); let todayChild = $derived(todayInTz(famTz));
let togglingIds = $state<string>(''); let togglingIds = $state<Set<string>>(new Set());
// ─── Payday countdown (child view) ─── // ─── Payday countdown (child view) ───
let nowMs = $state(Date.now()); let nowMs = $state(Date.now());
@@ -622,6 +623,12 @@
const rDate = (existingReward?.date || '').slice(0, 10); const rDate = (existingReward?.date || '').slice(0, 10);
const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd; const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd;
const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed'; const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed';
// A claimed reward from a previous week with no fresh progress
// means the goal is done — drop the card entirely instead of
// showing a stale 0% chase.
const staleClaimed =
!!existingReward && !rewardInWeek && !rewardOutstanding && current === 0;
if (staleClaimed) return null;
const achieved = existingReward const achieved = existingReward
? rewardInWeek || rewardOutstanding ? rewardInWeek || rewardOutstanding
: isPercent : isPercent
@@ -652,7 +659,8 @@
periodStart: pStart, periodStart: pStart,
periodEnd: pEnd periodEnd: pEnd
}; };
}); })
.filter((g) => g !== null);
}); });
// ─── Goal celebrations (bonus rewards achieved) ─── // ─── Goal celebrations (bonus rewards achieved) ───
@@ -823,8 +831,8 @@
async function toggle(chore: AssignedChore) { async function toggle(chore: AssignedChore) {
if (accessDisabled) return; if (accessDisabled) return;
if (chore.isTodo && isTodoExpired(chore)) return; if (chore.isTodo && isTodoExpired(chore)) return;
if (togglingIds) return; if (togglingIds.has(chore.id)) return;
togglingIds = chore.id; togglingIds = new Set(togglingIds).add(chore.id);
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild); const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild);
if (wasCompleted) { if (wasCompleted) {
@@ -875,7 +883,9 @@
} }
console.error('Toggle failed:', e); console.error('Toggle failed:', e);
} finally { } finally {
togglingIds = ''; const next = new Set(togglingIds);
next.delete(chore.id);
togglingIds = next;
} }
} }
@@ -1492,7 +1502,7 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each dailyPending as chore} {#each dailyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}> <button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1509,7 +1519,7 @@
<p class="empty">All done!</p> <p class="empty">All done!</p>
{:else} {:else}
{#each weeklyPending as chore} {#each weeklyPending as chore}
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}> <button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
<span class="chore-value">{chore.value} {chore.type}</span> <span class="chore-value">{chore.value} {chore.type}</span>
@@ -1528,7 +1538,7 @@
class:todo-black={urgency === 'black'} class:todo-black={urgency === 'black'}
class:todo-expired={expired} class:todo-expired={expired}
onclick={() => toggle(todo)} onclick={() => toggle(todo)}
disabled={!!togglingIds || expired} disabled={togglingIds.has(todo.id) || expired}
> >
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="todo-main"> <span class="todo-main">
@@ -1556,7 +1566,7 @@
class:todo-black={urgency === 'black'} class:todo-black={urgency === 'black'}
class:todo-expired={expired} class:todo-expired={expired}
onclick={() => toggle(todo)} onclick={() => toggle(todo)}
disabled={!!togglingIds || expired} disabled={togglingIds.has(todo.id) || expired}
> >
<span class="checkbox">{@html circleIcon}</span> <span class="checkbox">{@html circleIcon}</span>
<span class="todo-main"> <span class="todo-main">
@@ -1590,7 +1600,7 @@
{#each completedToday as c} {#each completedToday as c}
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)} {@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
{#if chore} {#if chore}
<button class="chore done" onclick={() => toggle(chore)} disabled={!!togglingIds}> <button class="chore done" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
<span class="checkbox">{@html checkCircleIcon}</span> <span class="checkbox">{@html checkCircleIcon}</span>
<span class="chore-name">{choreName(chore)}</span> <span class="chore-name">{choreName(chore)}</span>
{#if chore.type === 'emoji'} {#if chore.type === 'emoji'}
@@ -488,7 +488,7 @@
{/if} {/if}
{/each} {/each}
<div class="col-card-foot"> <div class="col-card-foot">
<span class="click-edit">Click to edit</span> <span class="click-edit">Mark as complete</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -565,7 +565,7 @@
</div> </div>
{/if} {/if}
<div class="col-card-foot"> <div class="col-card-foot">
<span class="click-edit">Click to edit</span> <span class="click-edit">Mark as complete</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -644,7 +644,7 @@
{/if} {/if}
{/if} {/if}
<div class="col-card-foot"> <div class="col-card-foot">
<span class="click-edit">Click to edit</span> <span class="click-edit">Mark as complete</span>
</div> </div>
</div> </div>
{/each} {/each}
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.10.6" "version": "1.10.7"
} }