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;
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
{#if claimToast}
|
||||
<div class="claim-toast">{claimToast}</div>
|
||||
{/if}
|
||||
<Footer />
|
||||
<Footer sidebar />
|
||||
</div>
|
||||
{#if chatStore.open}
|
||||
<button class="chat-backdrop" onclick={() => chatStore.closeChat()} aria-label="Close chat"
|
||||
|
||||
@@ -179,9 +179,11 @@
|
||||
: (data.templates as ChoreTemplate[])
|
||||
);
|
||||
let assigned = $derived(
|
||||
famStore.initialized
|
||||
? (famStore.assigned as AssignedChore[])
|
||||
: (data.assigned as AssignedChore[])
|
||||
(
|
||||
famStore.initialized
|
||||
? (famStore.assigned as AssignedChore[])
|
||||
: (data.assigned as AssignedChore[])
|
||||
).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a))
|
||||
);
|
||||
let completions = $derived(
|
||||
famStore.initialized
|
||||
@@ -264,7 +266,7 @@
|
||||
if (role === 'parent' || !memberId) return [];
|
||||
const activeSeasonIds = new Set(famStore.seasons.filter((s) => s.active).map((s) => s.id));
|
||||
return assigned.filter((a) => {
|
||||
if (a.memberId !== memberId) return false;
|
||||
if (a.memberId !== null && a.memberId !== memberId) return false;
|
||||
if (a.isTodo) return false;
|
||||
if (!a.seasonIds || a.seasonIds.length === 0) return true;
|
||||
if (activeSeasonIds.size === 0) return true;
|
||||
@@ -275,7 +277,7 @@
|
||||
// Todos — one-off items with deadlines (sorted: soonest first)
|
||||
let memberTodos = $derived.by(() =>
|
||||
assigned
|
||||
.filter((a) => a.memberId === memberId && a.isTodo)
|
||||
.filter((a) => (a.memberId === null || a.memberId === memberId) && a.isTodo)
|
||||
.sort((a, b) => {
|
||||
const da = a.completeBy || '9999-99-99';
|
||||
const db = b.completeBy || '9999-99-99';
|
||||
@@ -347,7 +349,9 @@
|
||||
// non-todo chores for the member (no season filter), weighted by the
|
||||
// number of days in the displayed period — so the hero total agrees
|
||||
// with the Goals progress instead of showing a different denominator.
|
||||
const chores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
|
||||
const chores = assigned.filter(
|
||||
(a) => (a.memberId === null || a.memberId === memberId) && !a.isTodo
|
||||
);
|
||||
const periodDays =
|
||||
Math.round(
|
||||
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) /
|
||||
@@ -491,7 +495,9 @@
|
||||
// ─── Threshold Goals (bonus configs the member is chasing) ───
|
||||
let thresholdGoals = $derived.by(() => {
|
||||
// Member's assigned chores (non-todo) — used to compute period potential.
|
||||
const myChores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
|
||||
const myChores = assigned.filter(
|
||||
(a) => (a.memberId === null || a.memberId === memberId) && !a.isTodo
|
||||
);
|
||||
return bonusConfigs
|
||||
.filter((cfg) => {
|
||||
if (cfg.status !== 'active') return false;
|
||||
@@ -674,6 +680,14 @@
|
||||
return completions.some((c) => c.assignedChoreId === todoId);
|
||||
}
|
||||
|
||||
// A todo whose completeBy has passed without being completed is expired —
|
||||
// no longer doable for the rest of the week.
|
||||
function isTodoExpired(todo: AssignedChore): boolean {
|
||||
if (isTodoDone(todo.id)) return false;
|
||||
if (!todo.completeBy) return false;
|
||||
return todo.completeBy < todayChild;
|
||||
}
|
||||
|
||||
function findCompletion(chore: AssignedChore) {
|
||||
const match = (c: Completion) =>
|
||||
chore.isTodo
|
||||
@@ -689,6 +703,7 @@
|
||||
|
||||
async function toggle(chore: AssignedChore) {
|
||||
if (accessDisabled) return;
|
||||
if (chore.isTodo && isTodoExpired(chore)) return;
|
||||
if (togglingIds) return;
|
||||
togglingIds = chore.id;
|
||||
|
||||
@@ -1340,6 +1355,7 @@
|
||||
{/each}
|
||||
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
|
||||
{@const urgency = todoUrgency(todo)}
|
||||
{@const expired = isTodoExpired(todo)}
|
||||
{#if todo.type === 'emoji'}
|
||||
<button
|
||||
class="chore todo-chore"
|
||||
@@ -1348,14 +1364,17 @@
|
||||
class:todo-orange={urgency === 'orange'}
|
||||
class:todo-red={urgency === 'red'}
|
||||
class:todo-black={urgency === 'black'}
|
||||
class:todo-expired={expired}
|
||||
onclick={() => toggle(todo)}
|
||||
disabled={!!togglingIds}
|
||||
disabled={!!togglingIds || expired}
|
||||
>
|
||||
<span class="checkbox">{@html circleIcon}</span>
|
||||
<span class="todo-main">
|
||||
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||||
{#if todo.completeBy}
|
||||
{#if todo.completeBy === todayChild}
|
||||
{#if expired}
|
||||
<span class="todo-due due-expired">EXPIRED</span>
|
||||
{:else if todo.completeBy === todayChild}
|
||||
<span class="todo-due due-today">DUE TODAY!</span>
|
||||
{:else}
|
||||
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
|
||||
@@ -1373,14 +1392,17 @@
|
||||
class:todo-orange={urgency === 'orange'}
|
||||
class:todo-red={urgency === 'red'}
|
||||
class:todo-black={urgency === 'black'}
|
||||
class:todo-expired={expired}
|
||||
onclick={() => toggle(todo)}
|
||||
disabled={!!togglingIds}
|
||||
disabled={!!togglingIds || expired}
|
||||
>
|
||||
<span class="checkbox">{@html circleIcon}</span>
|
||||
<span class="todo-main">
|
||||
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||||
{#if todo.completeBy}
|
||||
{#if todo.completeBy === todayChild}
|
||||
{#if expired}
|
||||
<span class="todo-due due-expired">EXPIRED</span>
|
||||
{:else if todo.completeBy === todayChild}
|
||||
<span class="todo-due due-today">DUE TODAY!</span>
|
||||
{:else}
|
||||
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
|
||||
@@ -2372,6 +2394,20 @@
|
||||
.chore.todo-black {
|
||||
border-left-color: #1f2937;
|
||||
}
|
||||
.chore.todo-expired {
|
||||
border-left-color: #9ca3af;
|
||||
background: #f3f4f6;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.chore.todo-expired .chore-name {
|
||||
text-decoration: line-through;
|
||||
color: #6b7280;
|
||||
}
|
||||
.todo-due.due-expired {
|
||||
color: #ef4444;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.todo-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { pbUser } from '$lib/server/pocketbase';
|
||||
import { cleanupExpiredTodos } from '$lib/server/services/chores';
|
||||
import type { ChoreTemplate, Member, AssignedChore, Completion, Season } from '$lib/types';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const pb = pbUser(event);
|
||||
// Defensive sweep: purge todos whose deadline was in a prior week. Runs as
|
||||
// the parent (delete permission), no-ops if there's nothing to remove.
|
||||
await cleanupExpiredTodos(pb, famId).catch(() => {});
|
||||
const [templates, members, assigned, seasons, completions] = await Promise.all([
|
||||
pb.collection('chore_templates').getFullList({ filter: `(famId = '${famId}' || global = true)` }),
|
||||
pb.collection('users').getFullList({
|
||||
|
||||
@@ -28,8 +28,10 @@
|
||||
// Assign-from-template modal (platform templates are copied into the family
|
||||
// as assigned chores; the fam admin amends before saving).
|
||||
let assignModal = $state<{ t: ChoreTemplate; memberId: string } | null>(null);
|
||||
let assignStep = $state(1);
|
||||
let assignName = $state('');
|
||||
let assignDescription = $state('');
|
||||
let assignTarget = $state<'member' | 'shared'>('member');
|
||||
let assignColor = $state('');
|
||||
let assignIcon = $state('');
|
||||
let assignFreq = $state('daily');
|
||||
@@ -37,6 +39,9 @@
|
||||
let assignValue = $state(10);
|
||||
let assignSeasonIds = $state<string[]>([]);
|
||||
|
||||
const STEP_SVG = (n: number) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><text x="12" y="16" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor" stroke="none">${n}</text></svg>`;
|
||||
|
||||
// Todo form state
|
||||
let todoName = $state('');
|
||||
let todoType = $state<'points' | 'money' | 'emoji'>('points');
|
||||
@@ -123,6 +128,10 @@
|
||||
return assigned.filter((a) => a.memberId === memberId && !a.isTodo);
|
||||
}
|
||||
|
||||
function sharedChoresFiltered(): AssignedChore[] {
|
||||
return filteredAssigned.filter((a) => !a.memberId && !a.isTodo);
|
||||
}
|
||||
|
||||
function isGlobalChore(a: AssignedChore): boolean {
|
||||
return !a.seasonIds || a.seasonIds.length === 0;
|
||||
}
|
||||
@@ -169,6 +178,8 @@
|
||||
const t = templates.find((x) => x.id === tid);
|
||||
if (!t) return;
|
||||
assignModal = { t, memberId };
|
||||
assignStep = 1;
|
||||
assignTarget = memberId ? 'member' : 'shared';
|
||||
assignName = t.name || '';
|
||||
assignDescription = t.description || '';
|
||||
assignColor = t.color || '';
|
||||
@@ -183,8 +194,9 @@
|
||||
if (!assignModal) return;
|
||||
const s = page.data.session as any;
|
||||
if (!s) return;
|
||||
const isShared = assignTarget === 'shared';
|
||||
const body: Record<string, unknown> = {
|
||||
memberId: assignModal.memberId,
|
||||
memberId: isShared ? '' : assignModal.memberId,
|
||||
templateId: assignModal.t.id,
|
||||
customName: assignName,
|
||||
description: assignDescription,
|
||||
@@ -192,7 +204,8 @@
|
||||
icon: assignIcon,
|
||||
frequency: assignFreq,
|
||||
type: assignType,
|
||||
value: assignValue
|
||||
value: assignValue,
|
||||
shared: isShared
|
||||
};
|
||||
if (assignSeasonIds.length) body.seasonIds = assignSeasonIds;
|
||||
await fetch(`/api/admin/${s.famId}/assigned-chores`, {
|
||||
@@ -205,6 +218,7 @@
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
assignModal = null;
|
||||
assignStep = 1;
|
||||
}
|
||||
|
||||
// Accordion state per member: which section is open
|
||||
@@ -276,6 +290,14 @@
|
||||
return completions.some((c: Completion) => c.assignedChoreId === todoId);
|
||||
}
|
||||
|
||||
// A todo whose completeBy has passed without being completed is expired —
|
||||
// no longer doable for the rest of the week.
|
||||
function isTodoExpired(a: AssignedChore): boolean {
|
||||
if (isTodoCompleted(a.id)) return false;
|
||||
if (!a.completeBy) return false;
|
||||
return a.completeBy < todayInTz(famTz);
|
||||
}
|
||||
|
||||
// Traffic light for admin todo cards (same logic as child page)
|
||||
function todoUrgency(a: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
|
||||
if (!a.completeBy) return 'blue';
|
||||
@@ -369,10 +391,14 @@
|
||||
style="border-left-color:{outlineColor(t.color)}; background:{accentBg(t.color)}"
|
||||
>
|
||||
<div class="card-body">
|
||||
<TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} />
|
||||
<strong>{t.name}</strong>
|
||||
<span class="badge">{t.defaultFrequency}</span>
|
||||
<span class="badge type">{t.defaultType}</span>
|
||||
<span class="card-title-line">
|
||||
<TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} />
|
||||
<strong>{t.name}</strong>
|
||||
</span>
|
||||
<span class="card-badge-line">
|
||||
<span class="badge">{t.defaultFrequency}</span>
|
||||
<span class="badge type">{t.defaultType}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-value">
|
||||
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
|
||||
@@ -433,18 +459,20 @@
|
||||
<div class="accordion-body">
|
||||
{#each visibleTodosForMember(m.id) as a}
|
||||
{@const completed = isTodoCompleted(a.id)}
|
||||
{@const expired = isTodoExpired(a)}
|
||||
{@const urgency = todoUrgency(a)}
|
||||
<div
|
||||
class="todo-admin-card"
|
||||
class:todo-completed={completed}
|
||||
class:todo-blue-bg={!completed && urgency === 'blue'}
|
||||
class:todo-green-bg={!completed && urgency === 'green'}
|
||||
class:todo-orange-bg={!completed && urgency === 'orange'}
|
||||
class:todo-red-bg={!completed && urgency === 'red'}
|
||||
class:todo-black-bg={!completed && urgency === 'black'}
|
||||
onclick={() => !completed && openEdit(a)}
|
||||
class:todo-expired={expired && !completed}
|
||||
class:todo-blue-bg={!completed && !expired && urgency === 'blue'}
|
||||
class:todo-green-bg={!completed && !expired && urgency === 'green'}
|
||||
class:todo-orange-bg={!completed && !expired && urgency === 'orange'}
|
||||
class:todo-red-bg={!completed && !expired && urgency === 'red'}
|
||||
class:todo-black-bg={!completed && !expired && urgency === 'black'}
|
||||
onclick={() => !completed && !expired && openEdit(a)}
|
||||
role="button"
|
||||
tabindex={completed ? -1 : 0}
|
||||
tabindex={completed || expired ? -1 : 0}
|
||||
>
|
||||
<div class="todo-admin-body">
|
||||
<strong class="todo-admin-name">{a.customName || 'Todo'}</strong>
|
||||
@@ -468,6 +496,8 @@
|
||||
{#if completed}
|
||||
<span class="todo-completed-badge">{@html checkCircleIcon} TBC completed</span
|
||||
>
|
||||
{:else if expired}
|
||||
<span class="todo-expired-badge">EXPIRED</span>
|
||||
{:else}
|
||||
<button
|
||||
class="del-btn"
|
||||
@@ -497,11 +527,11 @@
|
||||
class="accordion-wrapper accordion-chores"
|
||||
class:open={accordionState[m.id]?.chores ?? true}
|
||||
>
|
||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||
<span class="accordion-title">
|
||||
<span class="accordion-icon">{@html choresIcon}</span>
|
||||
<span class="accordion-label">Chores</span>
|
||||
</span>
|
||||
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
|
||||
<span class="accordion-title">
|
||||
<span class="accordion-icon">{@html choresIcon}</span>
|
||||
<span class="accordion-label">Chores</span>
|
||||
</span>
|
||||
<span class="accordion-chevron"
|
||||
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>
|
||||
@@ -523,19 +553,23 @@
|
||||
)}"
|
||||
>
|
||||
<div class="card-body">
|
||||
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
|
||||
<strong>{a.customName || tName}</strong>
|
||||
<span class="badge">{a.frequency}</span>
|
||||
{#each a.seasonIds || [] as sid}
|
||||
<span
|
||||
class="season-dot"
|
||||
title={seasonName(sid)}
|
||||
style="background:{seasonColor(sid)}"
|
||||
></span>
|
||||
{/each}
|
||||
{#if (a.seasonIds || []).length === 0}
|
||||
<span class="badge season muted">All year</span>
|
||||
{/if}
|
||||
<span class="card-title-line">
|
||||
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
|
||||
<strong>{a.customName || tName}</strong>
|
||||
</span>
|
||||
<span class="card-badge-line">
|
||||
<span class="badge">{a.frequency}</span>
|
||||
{#each a.seasonIds || [] as sid}
|
||||
<span
|
||||
class="season-dot"
|
||||
title={seasonName(sid)}
|
||||
style="background:{seasonColor(sid)}"
|
||||
></span>
|
||||
{/each}
|
||||
{#if (a.seasonIds || []).length === 0}
|
||||
<span class="badge season muted">All year</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-value">
|
||||
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
|
||||
@@ -563,6 +597,89 @@
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Shared column -->
|
||||
<div
|
||||
class="column member-col shared-col"
|
||||
style="border-top-color: #6366f1; --mc: #6366f1"
|
||||
ondragover={handleDragOver}
|
||||
ondrop={(e) => handleDrop(e, '')}
|
||||
>
|
||||
<h3>
|
||||
<span class="dot" style="background:#6366f1"></span>
|
||||
Everyone
|
||||
</h3>
|
||||
<!-- CHORES accordion wrapper -->
|
||||
<div
|
||||
class="accordion-wrapper accordion-chores"
|
||||
class:open={accordionState.shared?.chores ?? true}
|
||||
>
|
||||
<button class="accordion-head" onclick={() => toggleAccordion('shared', 'chores')}>
|
||||
<span class="accordion-title">
|
||||
<span class="accordion-icon">{@html choresIcon}</span>
|
||||
<span class="accordion-label">Competitive Chores</span>
|
||||
</span>
|
||||
<span class="accordion-chevron"
|
||||
>{@html
|
||||
(accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>
|
||||
</button>
|
||||
<span class="accordion-count" style="background:#6366f1"
|
||||
>{sharedChoresFiltered().length}</span
|
||||
>
|
||||
{#if accordionState.shared?.chores ?? true}
|
||||
<div class="accordion-body">
|
||||
{#each sharedChoresFiltered() as a}
|
||||
{@const tName = templateName(a.templateId)}
|
||||
<div
|
||||
class="card assigned"
|
||||
onclick={() => openEdit(a)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(a.color)}"
|
||||
>
|
||||
<div class="card-body">
|
||||
<span class="card-title-line">
|
||||
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
|
||||
<strong>{a.customName || tName}</strong>
|
||||
</span>
|
||||
<span class="card-badge-line">
|
||||
<span class="badge">{a.frequency}</span>
|
||||
{#each a.seasonIds || [] as sid}
|
||||
<span
|
||||
class="season-dot"
|
||||
title={seasonName(sid)}
|
||||
style="background:{seasonColor(sid)}"
|
||||
></span>
|
||||
{/each}
|
||||
{#if (a.seasonIds || []).length === 0}
|
||||
<span class="badge season muted">All year</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-value">
|
||||
{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : `${a.value} pts`}
|
||||
</div>
|
||||
<button
|
||||
class="del-btn"
|
||||
title="Remove assignment"
|
||||
onclick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const s = page.data.session as any;
|
||||
if (!s) return;
|
||||
await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }
|
||||
});
|
||||
}}>×</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">Drop a chore here</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if members.length < 3}
|
||||
{#each Array(3 - members.length) as _}
|
||||
<div class="column column-placeholder"></div>
|
||||
@@ -598,80 +715,122 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Assign from Template Modal -->
|
||||
<!-- Assign from Template Modal (Who / How / What wizard) -->
|
||||
{#if assignModal}
|
||||
<div
|
||||
class="overlay"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) assignModal = null;
|
||||
if (e.target === e.currentTarget) { assignModal = null; assignStep = 1; }
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Assign: {assignModal.t.name}</h3>
|
||||
<p class="hint">
|
||||
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)
|
||||
?.name}'s chores. Amend, then Assign.
|
||||
</p>
|
||||
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
|
||||
<label
|
||||
>Description <input
|
||||
bind:value={assignDescription}
|
||||
placeholder="Optional description"
|
||||
/></label
|
||||
>
|
||||
<div class="color-field">
|
||||
<span class="field-label">Colour</span>
|
||||
<div class="color-swatches">
|
||||
{#each TEMPLATE_COLORS as c}
|
||||
<button
|
||||
type="button"
|
||||
class="swatch"
|
||||
class:selected={assignColor === c.value}
|
||||
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
|
||||
'transparent'
|
||||
? '#e5e7eb'
|
||||
: c.value}"
|
||||
title={c.label}
|
||||
onclick={() => (assignColor = c.value)}
|
||||
></button>
|
||||
{/each}
|
||||
|
||||
<!-- Step tabs -->
|
||||
<div class="step-tabs" role="tablist">
|
||||
<button class="step-tab" class:active={assignStep === 1} onclick={() => (assignStep = 1)}>
|
||||
{@html STEP_SVG(1)}<span>Who</span>
|
||||
</button>
|
||||
<button class="step-tab" class:active={assignStep === 2} onclick={() => (assignStep = 2)}>
|
||||
{@html STEP_SVG(2)}<span>How</span>
|
||||
</button>
|
||||
<button class="step-tab" class:active={assignStep === 3} onclick={() => (assignStep = 3)}>
|
||||
{@html STEP_SVG(3)}<span>What</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if assignStep === 1}
|
||||
<!-- Step 1: Who -->
|
||||
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
|
||||
<label>Description <input bind:value={assignDescription} placeholder="Optional description" /></label>
|
||||
<label>
|
||||
Assign to
|
||||
<select bind:value={assignTarget} onchange={() => {
|
||||
if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
|
||||
assignModal = { ...assignModal!, memberId: members[0].id };
|
||||
}
|
||||
}}>
|
||||
<option value="member">A specific member</option>
|
||||
<option value="shared">Shared (all members)</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if assignTarget === 'member'}
|
||||
<label>
|
||||
Member
|
||||
<select bind:value={assignModal.memberId}>
|
||||
{#each members as m}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{:else if assignStep === 2}
|
||||
<!-- Step 2: How -->
|
||||
<label>
|
||||
Frequency
|
||||
<select bind:value={assignFreq}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select bind:value={assignType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="money">Money</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value
|
||||
<input type="number" step="any" bind:value={assignValue} />
|
||||
</label>
|
||||
<div class="season-field">
|
||||
<span class="field-label">Seasons</span>
|
||||
{@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
|
||||
</div>
|
||||
</div>
|
||||
<label class="icon-row"
|
||||
><span class="icon-preview"
|
||||
><TemplateIcon name={assignIcon} size={22} color={outlineColor(assignColor)} /></span
|
||||
>
|
||||
<select bind:value={assignIcon}>
|
||||
<option value="">None</option>
|
||||
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Frequency
|
||||
<select bind:value={assignFreq}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select bind:value={assignType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="money">Money</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value
|
||||
<input type="number" step="any" bind:value={assignValue} />
|
||||
</label>
|
||||
<div class="season-field">
|
||||
<span class="field-label">Seasons</span>
|
||||
{@render seasonPills(seasons, assignSeasonIds, (v: string[]) => (assignSeasonIds = v))}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Step 3: What -->
|
||||
<div class="color-field">
|
||||
<span class="field-label">Colour</span>
|
||||
<div class="color-swatches">
|
||||
{#each TEMPLATE_COLORS as c}
|
||||
<button
|
||||
type="button"
|
||||
class="swatch"
|
||||
class:selected={assignColor === c.value}
|
||||
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
|
||||
'transparent'
|
||||
? '#e5e7eb'
|
||||
: c.value}"
|
||||
title={c.label}
|
||||
onclick={() => (assignColor = c.value)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<label class="icon-row"
|
||||
><span class="icon-preview"
|
||||
><TemplateIcon name={assignIcon} size={22} color={outlineColor(assignColor)} /></span
|
||||
>
|
||||
<select bind:value={assignIcon}>
|
||||
<option value="">None</option>
|
||||
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
|
||||
<button type="button" onclick={assignChore}>Assign</button>
|
||||
{#if assignStep > 1}
|
||||
<button type="button" onclick={() => assignStep--}>Back</button>
|
||||
{:else}
|
||||
<button type="button" onclick={() => { assignModal = null; assignStep = 1; }}>Cancel</button>
|
||||
{/if}
|
||||
{#if assignStep < 3}
|
||||
<button type="button" class="primary" onclick={() => assignStep++}>Next</button>
|
||||
{:else}
|
||||
<button type="button" class="primary" onclick={assignChore}>Assign</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -992,9 +1151,9 @@
|
||||
}
|
||||
.template-list .card.template .card-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.template-list {
|
||||
@@ -1007,12 +1166,14 @@
|
||||
|
||||
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
||||
.kanban {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: 450px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.column {
|
||||
flex: 0 0 450px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-top: 4px solid #6366f1;
|
||||
border-radius: 12px;
|
||||
@@ -1085,7 +1246,7 @@
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
transition:
|
||||
@@ -1100,9 +1261,21 @@
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.card-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-badge-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.card-body strong {
|
||||
font-size: 0.85rem;
|
||||
@@ -1129,6 +1302,13 @@
|
||||
background: #fce7f3;
|
||||
color: #be185d;
|
||||
}
|
||||
.shared-badge {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
.shared-col {
|
||||
flex: 0 0 450px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1374,6 +1554,38 @@
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
.modal-actions button.primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
.step-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.step-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.step-tab.active {
|
||||
color: #6366f1;
|
||||
background: #eef2ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.step-tab:hover:not(.active) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* ── Accordion wrappers ── */
|
||||
.accordion-wrapper {
|
||||
@@ -1587,6 +1799,32 @@
|
||||
height: 0.95em;
|
||||
}
|
||||
|
||||
/* ── Expired todo ── */
|
||||
.todo-admin-card.todo-expired {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
background: #f3f4f6;
|
||||
border-left-color: #9ca3af;
|
||||
}
|
||||
.todo-admin-card.todo-expired:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.todo-admin-card.todo-expired .todo-admin-name {
|
||||
text-decoration: line-through;
|
||||
color: #6b7280;
|
||||
}
|
||||
.todo-expired-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Points badge ── */
|
||||
.badge-pts {
|
||||
background: #e0e7ff;
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Chewy';
|
||||
src: url('/fonts/Chewy/chewy-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-tertiary: 'Chewy', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user