update footer | add shared chores | expire todos if not completed
This commit is contained in:
@@ -1,17 +1,62 @@
|
||||
<script lang="ts">
|
||||
import FamDone from './FamDone.svelte';
|
||||
|
||||
// Offset the footer content past the fixed sidebar on fam-scoped pages
|
||||
// (220px desktop / 56px mobile). Standalone pages (landing, pricing, admin)
|
||||
// have no sidebar and leave this false.
|
||||
let { sidebar = false } = $props();
|
||||
</script>
|
||||
|
||||
<footer class="app-footer">
|
||||
<span class="footer-text"><FamDone /></span>
|
||||
<footer class="bg-slate-100 text-[#4c1d95]">
|
||||
<div class="footer-inner px-8 py-16" class:sidebar>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="footer-star">✦</span>
|
||||
<span class="footer-copy"
|
||||
>© {new Date().getFullYear()}
|
||||
<span class="footer-text font-bold text-xl"><FamDone /></span> All rights reserved.</span
|
||||
>
|
||||
<aside class="text-text-200 text-wash-300 flex flex-col justify-end text-right text-sm">
|
||||
<span>Built by humans</span>
|
||||
<a href="https://threejjjs.xyz">
|
||||
<h5 class="m-0 text-right font-tertiary text-xl md:text-3xl">
|
||||
three<span class="opacity-55">jjj</span>s
|
||||
<br />
|
||||
</h5>
|
||||
</a>
|
||||
<span class="text-xs">Interactive Tech</span>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.app-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
/* Offset the footer content past the fixed sidebar so it aligns with the
|
||||
app-main content column (220px desktop / 56px mobile). */
|
||||
.footer-inner.sidebar {
|
||||
margin-left: 220px;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.footer-inner.sidebar {
|
||||
margin-left: 56px;
|
||||
}
|
||||
}
|
||||
.footer-star {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
color: #4c1d95;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.footer-copy {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.footer-star {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.footer-copy {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -442,6 +442,8 @@ async function ensureAssignedChoreFields(): Promise<void> {
|
||||
if (!has('icon')) needed.push({ name: 'icon', type: 'text', required: false });
|
||||
if (!has('color')) needed.push({ name: 'color', type: 'text', required: false });
|
||||
if (!has('emoji')) needed.push({ name: 'emoji', type: 'text', required: false });
|
||||
// Shared chores: memberId becomes optional, shared boolean tracks original mode
|
||||
if (!has('shared')) needed.push({ name: 'shared', type: 'bool', required: false });
|
||||
// Todos can be celebration-only (emoji) as well as points/money.
|
||||
let changed = !!needed.length;
|
||||
const typeField = col.fields.find((f: any) => f.name === 'type');
|
||||
@@ -455,6 +457,12 @@ async function ensureAssignedChoreFields(): Promise<void> {
|
||||
tplField.required = false;
|
||||
changed = true;
|
||||
}
|
||||
// memberId must be optional for shared chores (null = shared with all members)
|
||||
const memberField = col.fields.find((f: any) => f.name === 'memberId');
|
||||
if (memberField && memberField.required) {
|
||||
memberField.required = false;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
await updateCollection(col.id, { ...col, fields: [...col.fields] });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'` }),
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -84,7 +84,8 @@ export interface ChoreTemplate {
|
||||
export interface AssignedChore {
|
||||
id: string;
|
||||
famId: string;
|
||||
memberId: string;
|
||||
memberId: string | null; // null = shared with all members
|
||||
shared?: boolean; // true = initially created as shared chore
|
||||
templateId?: string;
|
||||
frequency: Frequency;
|
||||
type: RewardType;
|
||||
|
||||
Reference in New Issue
Block a user