update footer | add shared chores | expire todos if not completed

This commit is contained in:
JCEEE
2026-09-03 11:50:51 +01:00
parent 8cec99715a
commit 68a0851467
14 changed files with 531 additions and 126 deletions
+35 -1
View File
@@ -1,10 +1,13 @@
import { famMeta } from './fam';
import { weekStart } from '@shared/timezone';
// Aggregated kanban payload for a single member (child session).
export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && (memberId = '${memberId}' || memberId = '')`
}),
pb.collection('completions').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'` })
@@ -20,4 +23,35 @@ export async function myChores(pb: any, famId: string, memberId: string) {
paydayTime,
timezone: tz
};
}
// Purge stale todos. A todo is a one-off item with a completeBy deadline; if it
// is never completed it lingers in `assigned_chores` forever (shown as EXPIRED).
// Once a new week has started past the deadline's week we delete it so it
// doesn't accumulate. Must be called with a client that can delete (parent /
// superuser) — child clients can't delete assigned_chores.
export async function cleanupExpiredTodos(pb: any, famId: string): Promise<number> {
const { payday, tz } = await famMeta(pb, famId);
const ws = weekStart(payday, tz);
const todos = await pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && isTodo = true && completeBy != ''`
});
const completions = await pb
.collection('completions')
.getFullList({ filter: `famId = '${famId}'` })
.catch(() => []);
const doneIds = new Set(completions.map((c: any) => c.assignedChoreId));
let removed = 0;
for (const todo of todos) {
// Only consider uncompleted todos whose deadline fell before this week.
if (doneIds.has(todo.id)) continue;
if (!todo.completeBy || todo.completeBy >= ws) continue;
await pb.collection('assigned_chores').delete(todo.id).catch(() => {});
removed++;
}
if (removed) console.log(`[chores] Purged ${removed} expired todo(s) for fam ${famId}`);
return removed;
}
@@ -5,7 +5,9 @@ import { evaluateFam } from './bonuses';
export async function myChores(pb: any, famId: string, memberId: string) {
const [templates, assigned, completions, rewards, bonusConfigs] = await Promise.all([
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}' && memberId = '${memberId}'` }),
pb.collection('assigned_chores').getFullList({
filter: `famId = '${famId}' && (memberId = '${memberId}' || memberId = '')`
}),
pb.collection('completions').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'` })
@@ -32,8 +34,15 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
.getFullList({ filter: `famId = '${famId}' && id = '${assignedChoreId}'` });
const chore = choreList?.[0];
const isTodo = chore?.isTodo;
const isShared = chore?.shared === true || !chore?.memberId;
let filter: string;
if (isTodo) {
if (isShared && !isTodo) {
// Shared non-todo: any completion in the period = already claimed
const { payday, tz } = await famMeta(pb, famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && date >= '${from}' && date < '${to}'`;
} else if (isTodo) {
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
} else {
const { payday, tz } = await famMeta(pb, famId);
@@ -49,6 +58,10 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
if (isTodo && existing[0].rewardId) {
await pb.collection('rewards').delete(existing[0].rewardId).catch(() => {});
}
// If this was a shared chore claimed by this member, un-share it
if (isShared && existing[0].memberId === memberId) {
await pb.collection('assigned_chores').update(assignedChoreId, { memberId: '' });
}
evaluateFam(pb, famId).catch(() => {});
return { completed: false };
}
@@ -74,6 +87,10 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
completedAt: new Date().toISOString(),
...(rewardId ? { rewardId } : {})
});
// Claim the shared chore by setting memberId
if (isShared) {
await pb.collection('assigned_chores').update(assignedChoreId, { memberId });
}
evaluateFam(pb, famId).catch(() => {});
return { completed: true, record };
}
@@ -83,7 +100,15 @@ export async function revoke(pb: any, famId: string, completionId: string) {
.collection('completions')
.getFullList({ filter: `famId = '${famId}' && id = '${completionId}'` });
if (!completions?.length) throw new Error('Completion not found');
const completion = completions[0];
await pb.collection('completions').delete(completionId);
// If this was a shared chore, clear memberId to make it available again
const choreList = await pb.collection('assigned_chores')
.getFullList({ filter: `famId = '${famId}' && id = '${completion.assignedChoreId}'` });
const chore = choreList?.[0];
if (chore?.shared === true) {
await pb.collection('assigned_chores').update(chore.id, { memberId: '' });
}
evaluateFam(pb, famId).catch(() => {});
return { revoked: true };
}
+3
View File
@@ -8,6 +8,7 @@ import {
} from '@shared/timezone';
import { slugify } from '@shared/slugify';
import { evaluateFam } from './bonuses';
import { cleanupExpiredTodos } from './chores';
function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto');
@@ -236,6 +237,8 @@ export async function completeWeek(pb: any, famId: string) {
const ws = weekStart(payday, tz);
await evaluateFam(pb, famId);
// Start of a new week → purge todos whose deadline was in a prior week.
await cleanupExpiredTodos(pb, famId).catch(() => {});
const [members, assigned, completions] = await Promise.all([
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
+2 -1
View File
@@ -51,7 +51,8 @@ export function createServices(
remove: (resource: string, famId: string, id: string) => crudSvc.remove(pb, resource, famId, id)
},
chores: {
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid)
myChores: (famId: string) => choresSvc.myChores(pb, famId, uid),
cleanupExpiredTodos: (famId: string) => choresSvc.cleanupExpiredTodos(pb, famId)
},
completions: {
toggle: (famId: string, body: { assignedChoreId: string; date: string }) =>