3419 lines
92 KiB
Svelte
3419 lines
92 KiB
Svelte
<script lang="ts">
|
||
import { page } from '$app/state';
|
||
import { enhance } from '$app/forms';
|
||
import { onMount } from 'svelte';
|
||
import { famStore } from '$lib/stores/fam.svelte';
|
||
import { memberApi } from '$lib/client/api';
|
||
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||
import {
|
||
checkCircleIcon,
|
||
revokeIcon,
|
||
circleIcon,
|
||
targetIcon,
|
||
sunIcon,
|
||
calendarIcon,
|
||
lockIcon,
|
||
clockIcon,
|
||
giftIcon,
|
||
handCoinsIcon,
|
||
coinsIcon,
|
||
walletIcon,
|
||
chevronLeftIcon,
|
||
chevronRightIcon
|
||
} from '$lib/components/icons';
|
||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||
import PiggyBank from '@lucide/svelte/icons/piggy-bank';
|
||
import { handleOf } from '@shared/slugify';
|
||
import {
|
||
weekStart as tzWeekStart,
|
||
addDaysStr,
|
||
todayInTz,
|
||
weekdayInTz,
|
||
wallClockToUtc,
|
||
resolveTz,
|
||
isCompleteForPeriod,
|
||
bonusWindow,
|
||
completionInWindow
|
||
} from '@shared/timezone';
|
||
|
||
let { data } = $props();
|
||
|
||
let role = $state(data.role || 'child');
|
||
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
|
||
let username = $derived(page.params.username);
|
||
|
||
// Access gate (frontend-only). When the fam is paused/disabled the member
|
||
// kanban interactions are locked — toggles no-op and the board renders empty.
|
||
let accessDisabled = $derived(!!page.data.famAccess?.disabled);
|
||
|
||
// ─── Family timezone (resolved) ───
|
||
const rawFamTz = $derived(data.timezone || data.fam?.timezone || 'auto');
|
||
const famTz = $derived(resolveTz(rawFamTz));
|
||
|
||
// ─── Parent View (admin overview) ───
|
||
let summary = $state(data.summary);
|
||
let today = $derived(todayInTz(famTz));
|
||
|
||
let toast = $state('');
|
||
|
||
let parentMembers = $derived(famStore.initialized ? famStore.members : data.members || []);
|
||
let parentTemplates = $derived(famStore.initialized ? famStore.templates : data.templates || []);
|
||
let parentAssigned = $derived(famStore.initialized ? famStore.assigned : data.assigned || []);
|
||
let parentRewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
|
||
let parentCompletions = $derived(
|
||
famStore.initialized ? famStore.completions : data.completions || []
|
||
);
|
||
let parentBonusConfigs = $derived(
|
||
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
|
||
);
|
||
|
||
// Admin overview stat tiles (summed across this week's member summaries).
|
||
let adminTiles = $derived.by(() => {
|
||
const sums = summary?.summaries || [];
|
||
return {
|
||
members: parentMembers.length,
|
||
pts: sums.reduce((t: number, s: any) => t + (Number(s.pointsEarned) || 0), 0),
|
||
money: sums.reduce((t: number, s: any) => t + (Number(s.moneyEarned) || 0), 0),
|
||
chores: sums.reduce((t: number, s: any) => t + (Number(s.choresCompleted) || 0), 0)
|
||
};
|
||
});
|
||
|
||
function todayCompletionsFor(memberId: string) {
|
||
return parentCompletions.filter(
|
||
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
|
||
);
|
||
}
|
||
// Yesterday's completions surface in the admin view for revoke — but never
|
||
// from the week's first day (yesterday would be payday = last week).
|
||
let yesterdayParent = $derived(addDaysStr(today, -1));
|
||
let yesterdayParentInWeek = $derived(
|
||
!!summary?.weekStart ? yesterdayParent >= (summary.weekStart as string).slice(0, 10) : true
|
||
);
|
||
function yesterdayCompletionsFor(memberId: string) {
|
||
if (!yesterdayParentInWeek) return [];
|
||
return parentCompletions.filter(
|
||
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === yesterdayParent
|
||
);
|
||
}
|
||
|
||
function choreNameFor(assignedChoreId: string): string {
|
||
const a = parentAssigned.find((x: any) => x.id === assignedChoreId);
|
||
if (!a) return '?';
|
||
const t = parentTemplates.find((x: any) => x.id === a.templateId);
|
||
return a.customName || t?.name || '?';
|
||
}
|
||
|
||
function memberInSummary(memberId: string) {
|
||
return summary?.summaries?.find((s: any) => s.memberId === memberId);
|
||
}
|
||
|
||
function claimableRewards() {
|
||
return parentRewards.filter(
|
||
(r: any) =>
|
||
r.rewardType !== 'points' && (r.status === 'unclaimed' || r.status === 'requested')
|
||
);
|
||
}
|
||
|
||
let hasClaimableRewards = $derived(claimableRewards().length > 0);
|
||
|
||
function memberClaimable(memberId: string) {
|
||
return claimableRewards().filter((r: any) => r.memberId === memberId);
|
||
}
|
||
|
||
function memberRequested(memberId: string) {
|
||
return parentRewards.filter(
|
||
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'requested'
|
||
);
|
||
}
|
||
function memberOutstanding(memberId: string) {
|
||
return parentRewards.filter(
|
||
(r: any) => r.rewardType !== 'points' && r.memberId === memberId && r.status === 'unclaimed'
|
||
);
|
||
}
|
||
|
||
let _confetti: any;
|
||
async function fire() {
|
||
if (!_confetti) _confetti = await import('@hiseb/confetti').then((m) => m.default);
|
||
_confetti({
|
||
count: 80,
|
||
size: 4,
|
||
velocity: 500,
|
||
fade: true,
|
||
position: { x: window.innerWidth / 2, y: 0 }
|
||
});
|
||
}
|
||
|
||
function showToast(msg: string) {
|
||
toast = msg;
|
||
setTimeout(() => (toast = ''), 4000);
|
||
}
|
||
|
||
function handleResult({ result }: any, onSuccess: ((d: any) => void) | null = null) {
|
||
const d = result.data || {};
|
||
if (d.error) showToast(d.error);
|
||
else if (result.type === 'success') {
|
||
if (onSuccess) onSuccess(d);
|
||
fire();
|
||
}
|
||
}
|
||
|
||
// Same "Bonus name – prize" split as the ledger, so prize rewards don't
|
||
// duplicate the full label in both columns of a claim row.
|
||
function rewardLabelParts(r: any): { name: string; prize: string } {
|
||
const label = r.label || '';
|
||
const idx = label.indexOf(' – ');
|
||
if (idx === -1) return { name: label, prize: label };
|
||
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
|
||
}
|
||
|
||
function rewardLabel(r: any): string {
|
||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||
if (r.rewardType === 'points') return `${r.value} pts`;
|
||
return rewardLabelParts(r).prize;
|
||
}
|
||
|
||
function manualConfigs() {
|
||
return parentBonusConfigs.filter((b: any) => {
|
||
if (!(b.status === 'active' && b.type === 'manual')) return false;
|
||
if (b.occurrence === 'once') {
|
||
const hasReward = parentRewards.some(
|
||
(r: any) => r.bonusConfigId === b.id && (!b.memberId || r.memberId === b.memberId)
|
||
);
|
||
if (hasReward) return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// Goals completed feed for the admin overview — this week's materialized
|
||
// bonus rewards (prize / points / cash), newest first. Undoing the
|
||
// underlying chore/points deletes the unclaimed reward server-side, so
|
||
// this list shrinks again in realtime via famStore.
|
||
let parentGoalsCompleted = $derived.by(() => {
|
||
const ws = (summary?.weekStart as string) || today;
|
||
return parentRewards
|
||
.filter((r: any) => r.bonusConfigId && (r.date || '').slice(0, 10) >= ws)
|
||
.map((r: any) => {
|
||
const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId);
|
||
const m = parentMembers.find((x: any) => x.id === r.memberId);
|
||
return {
|
||
id: r.id,
|
||
goalName: cfg?.name || r.label,
|
||
memberName: m?.name || 'Unknown',
|
||
color: m?.color || '#6366f1',
|
||
rewardText:
|
||
r.rewardType === 'cash'
|
||
? `£${Number(r.value).toFixed(2)}`
|
||
: r.rewardType === 'points'
|
||
? `${r.value} pts`
|
||
: cfg?.rewardValue || r.label,
|
||
date: (r.date || '').slice(0, 10)
|
||
};
|
||
})
|
||
.sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||
.slice(0, 10);
|
||
});
|
||
|
||
// ─── Child View (kanban) ───
|
||
let memberId = $state(data.memberId || '');
|
||
let pbToken = $state(data.token || '');
|
||
let famId = $state(data.famId || '');
|
||
let memberName = $state(data.memberName || '');
|
||
let memberColor = $state(data.memberColor || '#6366f1');
|
||
// Party mascot: live member record first (updates without re-login),
|
||
// server load as fallback, pig as default.
|
||
let partyEmoji = $derived(
|
||
(famStore.initialized
|
||
? (famStore.members as any[]).find((m) => m.id === memberId)?.partyEmoji
|
||
: null) ||
|
||
(data as any).partyEmoji ||
|
||
'🐖'
|
||
);
|
||
let editingName = $state(false);
|
||
let nameInput = $state('');
|
||
let showColorPicker = $state(false);
|
||
|
||
let templates = $derived(
|
||
famStore.initialized
|
||
? (famStore.templates as ChoreTemplate[])
|
||
: (data.templates as ChoreTemplate[])
|
||
);
|
||
let assigned = $derived(
|
||
(famStore.initialized
|
||
? (famStore.assigned as AssignedChore[])
|
||
: (data.assigned as AssignedChore[])
|
||
).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a))
|
||
);
|
||
let completions = $derived(
|
||
famStore.initialized
|
||
? (famStore.completions as Completion[])
|
||
: (data.completions as Completion[])
|
||
);
|
||
let rewards = $derived(
|
||
famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[])
|
||
);
|
||
let bonusConfigs = $derived(
|
||
famStore.initialized
|
||
? (famStore.bonusConfigs as BonusConfig[])
|
||
: (data.bonusConfigs as BonusConfig[])
|
||
);
|
||
|
||
let loading = $state(true);
|
||
let error = $state('');
|
||
|
||
let claimError = $state('');
|
||
let todayChild = $derived(todayInTz(famTz));
|
||
let togglingIds = $state<Set<string>>(new Set());
|
||
|
||
// ─── Payday countdown (child view) ───
|
||
let nowMs = $state(Date.now());
|
||
let eowFired = $state(false);
|
||
// Nyan-style payday party: rainbow banner + confetti when payday unlocks.
|
||
let paydayParty = $state(false);
|
||
let partyTimer: ReturnType<typeof setTimeout> | undefined;
|
||
function startPaydayParty() {
|
||
paydayParty = true;
|
||
fire();
|
||
if (partyTimer) clearTimeout(partyTimer);
|
||
partyTimer = setTimeout(() => (paydayParty = false), 15000);
|
||
}
|
||
$effect(() => {
|
||
const id = setInterval(() => (nowMs = Date.now()), 1000);
|
||
return () => clearInterval(id);
|
||
});
|
||
const paydayDay = $derived(data.payday != null ? Number(data.payday) : 1);
|
||
const paydayTime = $derived(data.paydayTime || '18:00');
|
||
const isPaydayToday = $derived(weekdayInTz(new Date(), famTz) === paydayDay);
|
||
const paydayTarget = $derived.by(() =>
|
||
new Date(wallClockToUtc(todayInTz(famTz), paydayTime, famTz)).getTime()
|
||
);
|
||
const secondsLeft = $derived(Math.max(0, Math.floor((paydayTarget - nowMs) / 1000)));
|
||
const countdownHours = $derived(Math.floor(secondsLeft / 3600));
|
||
const countdownMinutes = $derived(Math.floor((secondsLeft % 3600) / 60));
|
||
const countdownSecs = $derived(secondsLeft % 60);
|
||
const showCountdown = $derived(isPaydayToday && secondsLeft > 0);
|
||
$effect(() => {
|
||
if (role !== 'child' || !isPaydayToday) return;
|
||
if (secondsLeft > 0 || eowFired) return;
|
||
if (!pbToken || !famId) return;
|
||
eowFired = true;
|
||
memberApi.payday(famId).catch(() => {});
|
||
startPaydayParty();
|
||
});
|
||
|
||
function paydayWeekStart(): string {
|
||
return tzWeekStart(paydayDay, famTz);
|
||
}
|
||
|
||
function addDays(dateStr: string, days: number): string {
|
||
return addDaysStr(dateStr, days);
|
||
}
|
||
|
||
function monthStartStr(): string {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
|
||
}
|
||
|
||
const todayIso = todayInTz(famTz);
|
||
const currentWeek = paydayWeekStart();
|
||
let weekStart = $state(currentWeek);
|
||
let weekEnd = $derived(addDays(weekStart, 6));
|
||
|
||
const isCurrentWeek = $derived(weekStart === currentWeek);
|
||
// ─── Yesterday catch-up mode (child only) ───
|
||
// Before 12pm fam-local, and only when yesterday is still inside the
|
||
// current week (never across payday — e.g. payday Sunday → no yesterdays
|
||
// on Monday), the hero offers a "forgot yesterday?" button that flips the
|
||
// kanban to yesterday's date so missed chores can be marked late.
|
||
let yesterdayStr = $derived(addDaysStr(todayChild, -1));
|
||
let yesterdayInWeek = $derived(yesterdayStr >= currentWeek);
|
||
let famHour = $derived.by(() => {
|
||
try {
|
||
const parts = new Intl.DateTimeFormat('en-US', {
|
||
timeZone: famTz,
|
||
hour: 'numeric',
|
||
hour12: false
|
||
}).formatToParts(new Date(nowMs));
|
||
return Number(parts.find((p) => p.type === 'hour')?.value ?? 0) % 24;
|
||
} catch {
|
||
return new Date(nowMs).getHours();
|
||
}
|
||
});
|
||
let yesterdayAvailable = $derived(role === 'child' && yesterdayInWeek && famHour < 12);
|
||
let viewDate = $state<'today' | 'yesterday'>('today');
|
||
// Viewed day for the kanban: today normally, yesterday in catch-up mode.
|
||
let activeDate = $derived(
|
||
viewDate === 'yesterday' && yesterdayAvailable ? yesterdayStr : todayChild
|
||
);
|
||
let inYesterday = $derived(activeDate === yesterdayStr && yesterdayAvailable);
|
||
// Snap back to today when the mode stops being valid (past noon, or the
|
||
// day rolled over) so stale yesterday state can never linger.
|
||
$effect(() => {
|
||
if (!yesterdayAvailable && viewDate === 'yesterday') viewDate = 'today';
|
||
});
|
||
const daysLeft = $derived(
|
||
Math.max(
|
||
0,
|
||
Math.round(
|
||
// Payday closes the week on weekStart+6 (weeks run payday+1..payday).
|
||
(new Date(addDays(weekStart, 6) + 'T00:00:00').getTime() -
|
||
new Date(todayIso + 'T00:00:00').getTime()) /
|
||
86400000
|
||
)
|
||
)
|
||
);
|
||
|
||
let memberChores = $derived.by(() => {
|
||
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 !== 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;
|
||
return a.seasonIds.some((sid) => activeSeasonIds.has(sid));
|
||
});
|
||
});
|
||
|
||
// Todos — one-off items with deadlines (sorted: soonest first)
|
||
let memberTodos = $derived.by(() =>
|
||
assigned
|
||
.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';
|
||
if (da !== db) return da.localeCompare(db);
|
||
return (a.startDate || '').localeCompare(b.startDate || '');
|
||
})
|
||
);
|
||
|
||
// Celebration badge — completed todos from the last 24h, dismissed per-device
|
||
// via localStorage (no PB storage; auto-expires after 24h).
|
||
let celebrateDismissed = $state<string[]>([]);
|
||
if (typeof localStorage !== 'undefined') {
|
||
celebrateDismissed = JSON.parse(localStorage.getItem('celebrateDismissed') || '[]');
|
||
}
|
||
function dismissCelebrate(id: string) {
|
||
if (!celebrateDismissed.includes(id)) {
|
||
celebrateDismissed = [...celebrateDismissed, id];
|
||
try {
|
||
localStorage.setItem('celebrateDismissed', JSON.stringify(celebrateDismissed));
|
||
} catch {}
|
||
}
|
||
}
|
||
let celebrations = $derived.by(() => {
|
||
const cutoff = Date.now() - 24 * 3600 * 1000;
|
||
return completions
|
||
.filter((c: Completion) => {
|
||
if (c.id.startsWith('optimistic-')) return false;
|
||
if (c.memberId !== memberId) return false;
|
||
const at = c.completedAt ? new Date(c.completedAt).getTime() : 0;
|
||
if (!at || at < cutoff) return false;
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
return !!chore?.isTodo && !celebrateDismissed.includes(c.id);
|
||
})
|
||
.map((c: Completion) => {
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
const emoji =
|
||
chore?.type === 'emoji' ? chore.emoji || '🎉' : chore?.type === 'money' ? '💸' : '⭐';
|
||
return { id: c.id, emoji, name: chore?.customName || 'Todo' };
|
||
});
|
||
});
|
||
|
||
let dailyPending = $derived(
|
||
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, activeDate))
|
||
);
|
||
let weeklyPending = $derived(
|
||
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, activeDate))
|
||
);
|
||
|
||
// Traffic light: days until todo deadline (only non-emoji todos)
|
||
function todoUrgency(todo: AssignedChore): 'blue' | 'green' | 'orange' | 'red' | 'black' {
|
||
if (!todo.completeBy) return 'blue';
|
||
const days = Math.round(
|
||
(new Date(todo.completeBy + 'T00:00:00').getTime() -
|
||
new Date(todayChild + 'T00:00:00').getTime()) /
|
||
86400000
|
||
);
|
||
if (days <= 0) return 'black';
|
||
if (days === 1) return 'red';
|
||
if (days === 2) return 'orange';
|
||
if (days === 3) return 'green';
|
||
return 'blue';
|
||
}
|
||
let completedToday = $derived(
|
||
completions.filter((c) => c.memberId === memberId && c.date?.slice(0, 10) === activeDate)
|
||
);
|
||
|
||
let weeklyTotal = $derived.by(() => {
|
||
// Must match the pocket-money % threshold definition (and the family
|
||
// dashboard Done denominator): only the member's own chores count —
|
||
// personal assignments + shared chores they claimed. Unclaimed shared
|
||
// chores stay doable in the kanban but out of every denominator, so
|
||
// all views agree instead of drifting (e.g. 1/37 here vs 1/30 there).
|
||
const chores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
|
||
const periodDays =
|
||
Math.round(
|
||
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) /
|
||
86400000
|
||
) + 1;
|
||
return chores.reduce((sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1), 0);
|
||
});
|
||
|
||
let weekCompletions = $derived(
|
||
completions.filter(
|
||
(c) =>
|
||
c.memberId === memberId &&
|
||
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
||
(c.date?.slice(0, 10) || c.date) <= weekEnd
|
||
)
|
||
);
|
||
// Viewed-week daily activity for the hero background effect: completions
|
||
// per day across the 7-day window, plotted as a blurred white line.
|
||
let heroWeekActivity = $derived.by(() => {
|
||
const counts: number[] = [];
|
||
for (let i = 0; i < 7; i++) {
|
||
const d = addDays(weekStart, i);
|
||
counts.push(
|
||
weekCompletions.filter((c) => (c.date?.slice(0, 10) || c.date) === d).length
|
||
);
|
||
}
|
||
return counts;
|
||
});
|
||
let heroSpark = $derived.by(() => {
|
||
const max = Math.max(1, ...heroWeekActivity);
|
||
const pts = heroWeekActivity.map((v, i) => ({
|
||
x: 20 + i * ((700 - 40) / 6),
|
||
y: 100 - (v / max) * 72
|
||
}));
|
||
return {
|
||
line: pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '),
|
||
pts
|
||
};
|
||
});
|
||
let weeklyDone = $derived(weekCompletions.length);
|
||
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0);
|
||
|
||
// Pocket-money target as a % of chores, for the layered target bar under the
|
||
// hero progress. Only meaningful for a percent threshold (points targets
|
||
// can't be expressed as a fraction of chores).
|
||
let pmTargetPct = $derived.by(() => {
|
||
const pm = bonusConfigs.find(
|
||
(c) => c.isPocketMoney && c.status === 'active' && (!c.memberId || c.memberId === memberId)
|
||
);
|
||
if (!pm || pm.thresholdType !== 'percent') return null;
|
||
return Math.min(100, Math.max(0, Number(pm.criteriaValue) || 0));
|
||
});
|
||
|
||
let weekRewards = $derived(
|
||
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
|
||
);
|
||
|
||
// Money-type todos auto-create a matching cash reward when completed (see
|
||
// completions.toggle). Those rewards are already counted via the chore
|
||
// completions below, so skip any claimed reward that a completion linked to
|
||
// (via rewardId) to avoid double-counting the same earning event.
|
||
let autoRewardIds = $derived(
|
||
new Set(completions.map((c) => (c as any).rewardId).filter((id) => !!id))
|
||
);
|
||
|
||
let allTimeCash = $derived.by(
|
||
() =>
|
||
completions
|
||
.filter((c) => c.memberId === memberId)
|
||
.reduce((sum, c) => {
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
||
}, 0) +
|
||
rewards
|
||
.filter(
|
||
(r) =>
|
||
r.memberId === memberId &&
|
||
r.rewardType === 'cash' &&
|
||
r.status === 'claimed' &&
|
||
!autoRewardIds.has(r.id)
|
||
)
|
||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||
);
|
||
|
||
let allTimePoints = $derived.by(
|
||
() =>
|
||
completions
|
||
.filter((c) => c.memberId === memberId)
|
||
.reduce((sum, c) => {
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||
}, 0) +
|
||
rewards
|
||
.filter(
|
||
(r) =>
|
||
r.memberId === memberId &&
|
||
r.rewardType === 'points' &&
|
||
r.status === 'claimed' &&
|
||
!autoRewardIds.has(r.id)
|
||
)
|
||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||
);
|
||
|
||
// Week-to-date totals from chore completions (not rewards — rewards are created at EOW)
|
||
let weekChoreCash = $derived.by(() =>
|
||
weekCompletions.reduce((sum, c) => {
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
return sum + (chore?.type === 'money' ? Number(chore.value) : 0);
|
||
}, 0)
|
||
);
|
||
// Week's cash-reward earnings (pocket money, bonuses) count while still
|
||
// pending AND once claimed — auto-created money-todo rewards are skipped
|
||
// (already counted via the chore completion above).
|
||
let weekRewardCash = $derived.by(() =>
|
||
weekRewards
|
||
.filter((r) => r.rewardType === 'cash' && !autoRewardIds.has(r.id))
|
||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||
);
|
||
let weekCashTotal = $derived(weekChoreCash + weekRewardCash);
|
||
let weekChorePoints = $derived.by(() =>
|
||
weekCompletions.reduce((sum, c) => {
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||
}, 0)
|
||
);
|
||
|
||
let pendingCash = $derived.by(() =>
|
||
weekRewards
|
||
.filter((r) => r.rewardType === 'cash' && r.status !== 'claimed')
|
||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||
);
|
||
// Money owed to this member across all time (incl. carry-over from past weeks).
|
||
// Payday-gated rewards are excluded until their settleDate — the wallet shows
|
||
// them separately as a locked "pays out on payday" line.
|
||
let owedCash = $derived.by(() =>
|
||
rewards
|
||
.filter(
|
||
(r) =>
|
||
r.memberId === memberId &&
|
||
r.rewardType === 'cash' &&
|
||
r.status !== 'claimed' &&
|
||
!paydayLocked(r)
|
||
)
|
||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||
);
|
||
let weekPointsEarned = $derived.by(() =>
|
||
weekRewards
|
||
.filter((r) => r.rewardType === 'points')
|
||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||
);
|
||
const pendingRewards = $derived(
|
||
rewards.filter(
|
||
(r) =>
|
||
r.memberId === memberId &&
|
||
r.rewardType !== 'points' &&
|
||
(r.status === 'unclaimed' || r.status === 'requested')
|
||
)
|
||
);
|
||
// Payday-gated bonus rewards unlock on their settleDate at the fam's payday
|
||
// time (stamped server-side) — not midnight on the day.
|
||
function paydayLocked(r: any) {
|
||
if (r.claimable !== 'payday' || !r.settleDate) return false;
|
||
const settle = (r.settleDate as string).slice(0, 10);
|
||
if (todayChild < settle) return true;
|
||
if (todayChild > settle) return false;
|
||
return nowMs < new Date(wallClockToUtc(settle, paydayTime, famTz)).getTime();
|
||
}
|
||
let weekBonusTallies = $derived.by(() => {
|
||
const map = new Map<string, number>();
|
||
for (const r of weekRewards) {
|
||
if (r.memberId !== memberId) continue;
|
||
const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label;
|
||
map.set(name, (map.get(name) || 0) + 1);
|
||
}
|
||
// Badge every bonus earned this week — including a single occurrence
|
||
// (×1): the hero is the child's only at-a-glance signal, so nothing
|
||
// earned should stay invisible here.
|
||
return [...map.entries()].map(([name, count]) => ({ name, count }));
|
||
});
|
||
let anyWeekBonuses = $derived(weekRewards.length > 0);
|
||
|
||
// ─── Threshold Goals (bonus configs the member is chasing) ───
|
||
let thresholdGoals = $derived.by(() => {
|
||
// Member's own chores (non-todo) — personal + claimed shared, never
|
||
// unclaimed shared — used to compute period potential. Matches
|
||
// weeklyTotal above, the family dashboard, and the server's % definition.
|
||
const myChores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
|
||
return bonusConfigs
|
||
.filter((cfg) => {
|
||
if (cfg.status !== 'active') return false;
|
||
if (cfg.type === 'manual') return false;
|
||
if (cfg.target !== 'individual') return false;
|
||
return !cfg.memberId || cfg.memberId === memberId;
|
||
})
|
||
.map((cfg) => {
|
||
const win = bonusWindow(cfg, paydayDay, famTz);
|
||
const pStart = win.from || '';
|
||
const pEnd = win.to || '';
|
||
const periodDays =
|
||
pStart && pEnd
|
||
? Math.round(
|
||
(new Date(pEnd + 'T00:00:00').getTime() -
|
||
new Date(pStart + 'T00:00:00').getTime()) /
|
||
86400000
|
||
) + 1
|
||
: 1;
|
||
|
||
const periodCompletions = completions.filter(
|
||
(c) => c.memberId === memberId && completionInWindow(c, win)
|
||
);
|
||
|
||
const isPercent = cfg.thresholdType === 'percent';
|
||
const isCount = cfg.type === 'count';
|
||
|
||
// Resolve the per-member target chore (multi-target map wins,
|
||
// then the legacy scalar targetChoreId).
|
||
const memberTargetChoreId =
|
||
(cfg.targetChoreIds && cfg.targetChoreIds[memberId]) || cfg.targetChoreId || '';
|
||
|
||
// Count-type rewards pinned to a single chore scoped their count
|
||
// (and the period's reclaimable potential) to that chore only.
|
||
const countChores =
|
||
isCount && memberTargetChoreId
|
||
? myChores.filter((a) => a.id === memberTargetChoreId)
|
||
: myChores;
|
||
|
||
const totalPotential = countChores.reduce(
|
||
(sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1),
|
||
0
|
||
);
|
||
|
||
let current = 0;
|
||
if (isPercent) {
|
||
current = periodCompletions.length;
|
||
} else if (isCount) {
|
||
current = memberTargetChoreId
|
||
? periodCompletions.filter((c) => c.assignedChoreId === memberTargetChoreId).length
|
||
: periodCompletions.length;
|
||
} else {
|
||
current = periodCompletions.reduce((sum, c) => {
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||
}, 0);
|
||
}
|
||
|
||
const configured = Number(cfg.criteriaValue) || 0;
|
||
// Percent thresholds resolve to an absolute count against the
|
||
// period's potential (0 when no chores are assigned).
|
||
const target = isPercent ? Math.round((configured / 100) * totalPotential) : configured;
|
||
|
||
// Display percent = completed ÷ period potential (not ÷ threshold),
|
||
// so a 1-of-7 week reads 14%, not 25%.
|
||
const ratio = totalPotential > 0 ? current / totalPotential : 0;
|
||
const pct = isPercent
|
||
? totalPotential > 0
|
||
? Math.min(100, Math.round(ratio * 100))
|
||
: 0
|
||
: target > 0
|
||
? Math.min(100, Math.round((current / target) * 100))
|
||
: 0;
|
||
|
||
const existingReward = rewards.find(
|
||
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
|
||
);
|
||
// An earned reward only keeps lighting up the card while it's fresh
|
||
// (earned in the viewed week) or still outstanding (not yet claimed).
|
||
// Once claimed and the week rolls over, "Reward earned" disappears.
|
||
const rDate = (existingReward?.date || '').slice(0, 10);
|
||
const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd;
|
||
const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed';
|
||
const achieved = existingReward
|
||
? rewardInWeek || rewardOutstanding
|
||
: isPercent
|
||
? totalPotential > 0 && ratio * 100 >= configured
|
||
: target > 0 && current >= target;
|
||
|
||
const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts';
|
||
const targetLabel = `${configured} ${unit}`;
|
||
const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100;
|
||
const targetChore = isCount ? countChores[0] : null;
|
||
return {
|
||
config: cfg,
|
||
current,
|
||
criteriaValue: configured,
|
||
targetPct,
|
||
targetLabel,
|
||
target,
|
||
totalPotential,
|
||
isPercent,
|
||
targetChoreName: targetChore
|
||
? targetChore.customName ||
|
||
templates.find((t) => t.id === targetChore.templateId)?.name ||
|
||
''
|
||
: '',
|
||
pct,
|
||
achieved,
|
||
rewardStatus: existingReward?.status || null,
|
||
periodStart: pStart,
|
||
periodEnd: pEnd
|
||
};
|
||
});
|
||
});
|
||
|
||
// ─── Goal celebrations (bonus rewards achieved) ───
|
||
// A goal flips to achieved the moment its completions cross the threshold.
|
||
// We celebrate once per goal per day (localStorage, so the notice survives
|
||
// reloads but expires at the next 00:00), re-arm when the achievement is
|
||
// revoked (chore/points undone), and always show the reward value/prize.
|
||
const goalCelebratedKey = `goalCelebrated:${famId}`;
|
||
let goalCelebrated = $state<Record<string, string>>(
|
||
typeof localStorage !== 'undefined'
|
||
? (JSON.parse(localStorage.getItem(goalCelebratedKey) || '{}') as Record<string, string>)
|
||
: {}
|
||
);
|
||
let goalCelebrations = $state<{ id: string; name: string; reward: string }[]>([]);
|
||
|
||
function goalRewardLabel(g: { config: any }): string {
|
||
const cfg = g.config;
|
||
if (cfg.rewardType === 'cash') return `£${Number(cfg.rewardValue).toFixed(2)}`;
|
||
if (cfg.rewardType === 'prize') return cfg.rewardValue || 'prize';
|
||
return `${cfg.rewardValue} pts`;
|
||
}
|
||
|
||
function dismissGoalCelebrate(id: string) {
|
||
goalCelebrations = goalCelebrations.filter((c) => c.id !== id);
|
||
goalCelebrated = { ...goalCelebrated, [id]: todayChild };
|
||
try {
|
||
localStorage.setItem(goalCelebratedKey, JSON.stringify(goalCelebrated));
|
||
} catch {}
|
||
}
|
||
|
||
$effect(() => {
|
||
if (role !== 'child') return;
|
||
const today = todayChild;
|
||
let next: Record<string, string> = { ...goalCelebrated };
|
||
let changed = false;
|
||
// Yesterday's celebrations expire — a notice lives for its day only.
|
||
for (const [id, day] of Object.entries(next)) {
|
||
if (day !== today) {
|
||
delete next[id];
|
||
changed = true;
|
||
}
|
||
}
|
||
for (const g of thresholdGoals) {
|
||
if (g.achieved && next[g.config.id] !== today) {
|
||
next[g.config.id] = today;
|
||
changed = true;
|
||
goalCelebrations = [
|
||
{ id: g.config.id, name: g.config.name, reward: goalRewardLabel(g) },
|
||
...goalCelebrations.filter((c) => c.id !== g.config.id)
|
||
];
|
||
fire();
|
||
} else if (!g.achieved && next[g.config.id]) {
|
||
// Achievement revoked (chore/points undone) — drop the notice and
|
||
// re-arm so completing it again celebrates again.
|
||
delete next[g.config.id];
|
||
changed = true;
|
||
goalCelebrations = goalCelebrations.filter((c) => c.id !== g.config.id);
|
||
}
|
||
}
|
||
if (changed) {
|
||
goalCelebrated = next;
|
||
try {
|
||
localStorage.setItem(goalCelebratedKey, JSON.stringify(next));
|
||
} catch {}
|
||
}
|
||
});
|
||
|
||
function shortDate(dateStr: string): string {
|
||
const d = new Date(dateStr + 'T00:00:00');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
const mon = String(d.getMonth() + 1).padStart(2, '0');
|
||
return `${day}/${mon}/${String(d.getFullYear()).slice(2)}`;
|
||
}
|
||
const MONTHS = [
|
||
'January',
|
||
'February',
|
||
'March',
|
||
'April',
|
||
'May',
|
||
'June',
|
||
'July',
|
||
'August',
|
||
'September',
|
||
'October',
|
||
'November',
|
||
'December'
|
||
];
|
||
function headDate(): Date {
|
||
return new Date(activeDate + 'T00:00:00');
|
||
}
|
||
function headDay(): number {
|
||
return headDate().getDate();
|
||
}
|
||
function headDayOrdinal(): string {
|
||
const n = headDay();
|
||
if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
|
||
const s = ['th', 'st', 'nd', 'rd'];
|
||
return `${n}${s[n % 10] || s[0]}`;
|
||
}
|
||
function headWeekday(): string {
|
||
return headDate().toLocaleDateString(undefined, { weekday: 'long' });
|
||
}
|
||
function headMonth(): string {
|
||
const d = headDate();
|
||
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||
}
|
||
function navigate(amount: number) {
|
||
weekStart = addDays(weekStart, amount * 7);
|
||
}
|
||
function jumpToNow() {
|
||
weekStart = currentWeek;
|
||
}
|
||
|
||
onMount(async () => {
|
||
if (role === 'parent') return;
|
||
|
||
if (data.verified && data.famId && data.memberId) {
|
||
memberId = data.memberId;
|
||
famId = data.famId;
|
||
loading = false;
|
||
} else {
|
||
error = 'Please open your invite link to get access.';
|
||
loading = false;
|
||
}
|
||
});
|
||
|
||
function isCompleted(assignedChoreId: string, date: string): boolean {
|
||
const a = assigned.find((x) => x.id === assignedChoreId);
|
||
// Weekly chores are "done for the week" — complete if completed any day
|
||
// in the current week, not just today.
|
||
if (a?.frequency === 'weekly') {
|
||
const dates = completions
|
||
.filter((c) => c.assignedChoreId === assignedChoreId)
|
||
.map((c) => c.date);
|
||
return isCompleteForPeriod('weekly', paydayDay, famTz, dates);
|
||
}
|
||
return completions.some(
|
||
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
|
||
);
|
||
}
|
||
|
||
// Todos are one-off: done once the completion row exists, regardless of date.
|
||
function isTodoDone(todoId: string): boolean {
|
||
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
|
||
? c.assignedChoreId === chore.id
|
||
: chore.frequency === 'weekly'
|
||
? c.assignedChoreId === chore.id &&
|
||
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
||
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
|
||
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === activeDate;
|
||
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
||
return optimistic || completions.find(match);
|
||
}
|
||
|
||
async function toggle(chore: AssignedChore) {
|
||
if (accessDisabled) return;
|
||
if (!pbToken) {
|
||
showToast('Logged out — please log in again to update chores.');
|
||
return;
|
||
}
|
||
if (chore.isTodo && isTodoExpired(chore)) return;
|
||
if (togglingIds.has(chore.id)) return;
|
||
togglingIds = new Set(togglingIds).add(chore.id);
|
||
|
||
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, activeDate);
|
||
// Backdated (yesterday) completions are stamped for yesterday — never
|
||
// today's wall-clock time — so period/window logic can't double-count.
|
||
const stampDate = activeDate;
|
||
const stampAt =
|
||
stampDate === todayChild
|
||
? new Date().toISOString()
|
||
: new Date(wallClockToUtc(stampDate, '12:00', famTz)).toISOString();
|
||
if (wasCompleted) {
|
||
const existing = findCompletion(chore);
|
||
if (existing) {
|
||
famStore.applyRecord('completions', existing, 'delete');
|
||
}
|
||
} else {
|
||
famStore.applyRecord(
|
||
'completions',
|
||
{
|
||
id: 'optimistic-' + chore.id,
|
||
famId,
|
||
memberId,
|
||
assignedChoreId: chore.id,
|
||
date: stampDate,
|
||
completedAt: stampAt
|
||
} as Completion,
|
||
'create'
|
||
);
|
||
}
|
||
|
||
try {
|
||
await memberApi.toggleCompletion(famId, chore.id, stampDate, stampAt);
|
||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||
if (optimistic) {
|
||
famStore.applyRecord('completions', optimistic, 'delete');
|
||
}
|
||
} catch (e) {
|
||
// A stale assigned ID (parent re-saved chores while this tab was
|
||
// open in the background) leaves the chore untogglable. init() is
|
||
// a no-op when already initialized, so force a resync() to drop
|
||
// the dead card. Never re-add completions for the gone chore —
|
||
// that is what kept the error reoccurring after "refresh".
|
||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||
if (optimistic) {
|
||
famStore.applyRecord('completions', optimistic, 'delete');
|
||
}
|
||
try {
|
||
await famStore.resync();
|
||
} catch {}
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
showToast(
|
||
/CHORE_GONE/.test(msg)
|
||
? 'This chore was changed — list refreshed.'
|
||
: 'Toggle failed — please try again.'
|
||
);
|
||
console.error('Toggle failed:', e);
|
||
} finally {
|
||
const next = new Set(togglingIds);
|
||
next.delete(chore.id);
|
||
togglingIds = next;
|
||
}
|
||
}
|
||
|
||
function templateFor(chore: AssignedChore): ChoreTemplate | undefined {
|
||
return templates.find((t) => t.id === chore.templateId);
|
||
}
|
||
|
||
function choreName(chore: AssignedChore): string {
|
||
return chore.customName || templateFor(chore)?.name || 'Chore';
|
||
}
|
||
</script>
|
||
|
||
{#if role === 'parent'}
|
||
<!-- Parent View: Admin Overview -->
|
||
<ViewHeader
|
||
title={famStore.fam?.name || 'Dashboard'}
|
||
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : ''}
|
||
hero
|
||
/>
|
||
|
||
{#if toast}
|
||
<div class="toast">{toast}</div>
|
||
{/if}
|
||
|
||
<!-- WEEK-TO-DATE TILES -->
|
||
<div class="tiles">
|
||
<div class="tile tile-members">
|
||
<span class="tile-val">{adminTiles.members}</span>
|
||
<span class="tile-lbl">members</span>
|
||
</div>
|
||
<div class="tile tile-pts">
|
||
<span class="tile-val">{adminTiles.pts}</span>
|
||
<span class="tile-lbl">points this week</span>
|
||
</div>
|
||
<div class="tile tile-cash">
|
||
<span class="tile-val">£{adminTiles.money.toFixed(2)}</span>
|
||
<span class="tile-lbl">cash this week</span>
|
||
</div>
|
||
<div class="tile tile-chores">
|
||
<span class="tile-val">{adminTiles.chores}</span>
|
||
<span class="tile-lbl">chores done</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GOALS COMPLETED (mini cards, above the member/trigger/claims grid) -->
|
||
<div class="goals-completed">
|
||
{#if parentGoalsCompleted.length === 0}
|
||
<p class="empty">
|
||
No goals completed yet — rewards appear here the moment a member hits a target.
|
||
</p>
|
||
{:else}
|
||
<div class="goal-mini-grid">
|
||
{#each parentGoalsCompleted as g}
|
||
<div class="goal-mini">
|
||
<div class="gm-top">
|
||
<span class="dot" style="background:{g.color}"></span>
|
||
<span class="gm-name">🎉 {g.goalName}</span>
|
||
<span class="gm-member">{g.memberName}</span>
|
||
</div>
|
||
<div class="gm-bottom">
|
||
<span class="gm-reward">{g.rewardText}</span>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="kanban-scroll">
|
||
<div class="kanban-inner">
|
||
<CardGrid stackTablet>
|
||
<Card cols={1} title="Members">
|
||
{#each parentMembers as m}
|
||
{@const todays = todayCompletionsFor(m.id)}
|
||
{@const yesterdays = yesterdayCompletionsFor(m.id)}
|
||
{@const done = todays.length}
|
||
{@const s = memberInSummary(m.id)}
|
||
{@const pm = parentBonusConfigs.find(
|
||
(c: any) => c.isPocketMoney && c.memberId === m.id
|
||
)}
|
||
<div class="member-card">
|
||
<div class="card-header">
|
||
<span class="dot" style="background:{m.color}"></span>
|
||
<span class="member-name">{m.name}</span>
|
||
{#if !(pm && pm.rewardValue)}
|
||
<Button
|
||
href="/{famSlug}/{username}/rewards?s=pocketmoney"
|
||
variant="secondary"
|
||
size="sm">Pocket money</Button
|
||
>
|
||
{/if}
|
||
</div>
|
||
<div class="stats">
|
||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
|
||
</div>
|
||
<div class="pocket-row">
|
||
{#if pm && pm.rewardValue}
|
||
<span
|
||
>Pocket money: £{Number(pm.rewardValue).toFixed(2)} · {pm.criteriaValue || 0}%
|
||
of chores</span
|
||
>
|
||
{:else}
|
||
<span class="unset">Pocket money: amount not set</span>
|
||
{/if}
|
||
</div>
|
||
{#if done > 0}
|
||
<ul class="done-list">
|
||
{#each todays as c, i}
|
||
<li class="done-item" class:odd={i % 2 === 1}>
|
||
<span class="done-icon">{@html checkCircleIcon}</span>
|
||
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
|
||
<form
|
||
method="POST"
|
||
action="?/revoke"
|
||
use:enhance={() => {
|
||
return async (args) => handleResult(args);
|
||
}}
|
||
class="revoke-form"
|
||
>
|
||
<input type="hidden" name="id" value={c.id} />
|
||
<button type="submit" class="revoke-btn" title="Revoke"
|
||
>{@html revokeIcon}</button
|
||
>
|
||
</form>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
{#if yesterdays.length > 0}
|
||
<p class="section-label">Yesterday</p>
|
||
<ul class="done-list yesterday-list">
|
||
{#each yesterdays as c, i}
|
||
<li class="done-item" class:odd={i % 2 === 1}>
|
||
<span class="done-icon">{@html checkCircleIcon}</span>
|
||
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
|
||
<span class="yesterday-tag">yesterday</span>
|
||
<form
|
||
method="POST"
|
||
action="?/revoke"
|
||
use:enhance={() => {
|
||
return async (args) => handleResult(args);
|
||
}}
|
||
class="revoke-form"
|
||
>
|
||
<input type="hidden" name="id" value={c.id} />
|
||
<button type="submit" class="revoke-btn" title="Revoke"
|
||
>{@html revokeIcon}</button
|
||
>
|
||
</form>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</Card>
|
||
|
||
<Card cols={1} title="Triggers">
|
||
{#if manualConfigs().length === 0}
|
||
<p class="empty">No manual bonus configs.</p>
|
||
{:else}
|
||
{#each manualConfigs() as bc}
|
||
{@const val =
|
||
bc.rewardType === 'cash'
|
||
? `£${Number(bc.rewardValue).toFixed(2)}`
|
||
: bc.rewardType === 'points'
|
||
? `${bc.rewardValue} pts`
|
||
: bc.rewardValue}
|
||
{@const targeted = bc.memberId
|
||
? parentMembers.find((m: any) => m.id === bc.memberId)
|
||
: null}
|
||
<div class="trigger-card">
|
||
<div class="trigger-head">
|
||
<span class="trigger-name">{bc.name}</span>
|
||
<span class="trigger-value">{val}</span>
|
||
</div>
|
||
{#if targeted}
|
||
<form
|
||
method="POST"
|
||
action="?/trigger"
|
||
use:enhance={() => {
|
||
return async (args: any) => handleResult(args);
|
||
}}
|
||
>
|
||
<input type="hidden" name="configId" value={bc.id} />
|
||
<input type="hidden" name="memberId" value={targeted.id} />
|
||
<div class="trigger-row">
|
||
<span class="trigger-target" style="color:{targeted.color}"
|
||
>■ {targeted.name}</span
|
||
>
|
||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||
</div>
|
||
</form>
|
||
{:else}
|
||
<form
|
||
method="POST"
|
||
action="?/trigger"
|
||
use:enhance={() => {
|
||
return async (args: any) => handleResult(args);
|
||
}}
|
||
>
|
||
<input type="hidden" name="configId" value={bc.id} />
|
||
<div class="trigger-row">
|
||
<select name="memberId" class="trigger-select">
|
||
<option value="">Select member</option>
|
||
{#each parentMembers as m}
|
||
<option value={m.id}>{m.name}</option>
|
||
{/each}
|
||
</select>
|
||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||
</div>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
</Card>
|
||
|
||
<Card cols={1} title="Claims" accent={hasClaimableRewards ? '#f97316' : undefined} class={hasClaimableRewards ? 'claims-card' : ''}>
|
||
{#if claimableRewards().length === 0}
|
||
<div class="claims-empty">
|
||
{@html checkCircleIcon}<span>No outstanding claims</span>
|
||
</div>
|
||
{:else}
|
||
{@const familyOwed = claimableRewards()
|
||
.filter((r: any) => r.rewardType === 'cash')
|
||
.reduce((s: number, r: any) => s + Number(r.value), 0)}
|
||
{@const pendingCount = claimableRewards().filter(
|
||
(r: any) => r.status === 'requested'
|
||
).length}
|
||
|
||
<div class="claims-summary">
|
||
<div class="claims-stat">
|
||
<span class="claims-stat-icon">{@html handCoinsIcon}</span>
|
||
<div>
|
||
<div class="claims-stat-value">£{familyOwed.toFixed(2)}</div>
|
||
<div class="claims-stat-label">Total owed</div>
|
||
</div>
|
||
</div>
|
||
<div class="claims-stat pending">
|
||
<span class="claims-stat-icon pending">{@html clockIcon}</span>
|
||
<div>
|
||
<div class="claims-stat-value">{pendingCount}</div>
|
||
<div class="claims-stat-label">Awaiting approval</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{#each parentMembers as m}
|
||
{@const requested = memberRequested(m.id)}
|
||
{@const outstanding = memberOutstanding(m.id)}
|
||
{@const claimable = memberClaimable(m.id)}
|
||
{@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')}
|
||
{@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')}
|
||
{@const requestedTotal = cashRequested.reduce(
|
||
(sum: number, r: any) => sum + Number(r.value),
|
||
0
|
||
)}
|
||
{@const total = cashClaimable.reduce(
|
||
(sum: number, r: any) => sum + Number(r.value),
|
||
0
|
||
)}
|
||
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
||
{#if hasClaims}
|
||
<div class="member-claims">
|
||
<div class="member-claims-head">
|
||
<span class="dot" style="background:{m.color}"></span>
|
||
<span class="member-name">{m.name}</span>
|
||
{#if total > 0}
|
||
<span class="member-owed">£{total.toFixed(2)} owed</span>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if requested.length > 0}
|
||
<p class="section-label">Requested</p>
|
||
{#each requested as r}
|
||
<form
|
||
class="flex flex-wrap justify-between items-center"
|
||
method="POST"
|
||
action="?/claim"
|
||
use:enhance={() => {
|
||
return async (args: any) => handleResult(args);
|
||
}}
|
||
>
|
||
<input type="hidden" name="id" value={r.id} />
|
||
<div class="flex gap-4">
|
||
<span class="claim-icon"
|
||
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
|
||
>
|
||
<span class="claim-label">{rewardLabelParts(r).name}</span>
|
||
<span class="claim-value">{rewardLabel(r)}</span>
|
||
</div>
|
||
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
||
</form>
|
||
{/each}
|
||
{/if}
|
||
|
||
{#if outstanding.length > 0}
|
||
<p class="section-label">Outstanding</p>
|
||
{#each outstanding as r}
|
||
<div class="claim-item outstanding">
|
||
<span class="claim-icon"
|
||
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
|
||
>
|
||
<span class="claim-label">{rewardLabelParts(r).name}</span>
|
||
<span class="claim-value">
|
||
{rewardLabel(r)}
|
||
{#if paydayLocked(r)}
|
||
<span class="owe-locked"
|
||
>{@html lockIcon} {formatHumanDate(r.settleDate)}</span
|
||
>
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
<!-- Issue All: only when payday has run (rewards are requested) -->
|
||
{#if cashRequested.length > 0}
|
||
<form
|
||
class="issue-all-form flex items-center [&>button]:w-full mt-4"
|
||
method="POST"
|
||
action="?/issueAll"
|
||
use:enhance={() => {
|
||
return async (args: any) => handleResult(args);
|
||
}}
|
||
>
|
||
<input type="hidden" name="memberId" value={m.id} />
|
||
<Button type="submit" variant="success"
|
||
>Issue all · £{requestedTotal.toFixed(2)}</Button
|
||
>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
{/if}
|
||
</Card>
|
||
</CardGrid>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
<!-- Child View -->
|
||
{#if loading}
|
||
<p class="empty">Loading...</p>
|
||
{:else if error}
|
||
<p class="error">{error}</p>
|
||
<p class="empty">
|
||
<Button href={`/${famSlug}/join/${username}`} variant="primary" size="md">Join</Button>
|
||
</p>
|
||
{:else}
|
||
{#if owedCash > 0}
|
||
<div class="payday-banner">
|
||
🎉 You've earned <b>£{owedCash.toFixed(2)}</b> — go get it from your parent!
|
||
</div>
|
||
{/if}
|
||
{#if showCountdown}
|
||
<div class="payday-countdown">
|
||
<div class="pd-icon"><PiggyBank size={44} strokeWidth={2} /></div>
|
||
<div class="pd-body">
|
||
<p class="pd-title">Payday is today!</p>
|
||
<p class="pd-num">
|
||
{#if countdownHours > 0}
|
||
{countdownHours}h {countdownMinutes}m left
|
||
{:else}
|
||
{countdownMinutes}m {countdownSecs}s left
|
||
{/if}
|
||
</p>
|
||
<p class="pd-sub">Your earnings get settled at {paydayTime}</p>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if paydayParty}
|
||
<div class="payday-party" role="status" onclick={() => (paydayParty = false)}>
|
||
<span class="party-cat">{partyEmoji}</span>
|
||
<div class="party-body">
|
||
<p class="party-title">PAYDAY!!</p>
|
||
<p class="party-sub">Your cash is ready — go collect it! 🎉</p>
|
||
</div>
|
||
<span class="party-stars">🌈⭐🌈</span>
|
||
</div>
|
||
{/if}
|
||
<!-- HERO -->
|
||
<header class="hero">
|
||
<svg
|
||
class="hero-bg"
|
||
viewBox="0 0 700 120"
|
||
preserveAspectRatio="none"
|
||
aria-hidden="true"
|
||
>
|
||
<polyline
|
||
points={heroSpark.line}
|
||
fill="none"
|
||
stroke="#fff"
|
||
stroke-width="3"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
{#each heroSpark.pts as p}
|
||
<circle cx={p.x} cy={p.y} r="5" fill="#fff" />
|
||
{/each}
|
||
</svg>
|
||
<div class="hero-top">
|
||
<div class="hero-id">
|
||
<span
|
||
class="color-dot hero-dot"
|
||
style="background:{memberColor}"
|
||
role="button"
|
||
tabindex="0"
|
||
onclick={(e) => {
|
||
e.stopPropagation();
|
||
showColorPicker = !showColorPicker;
|
||
}}
|
||
onkeydown={(e) => e.key === 'Enter' && (showColorPicker = !showColorPicker)}
|
||
></span>
|
||
{#if editingName}
|
||
<input
|
||
type="text"
|
||
bind:value={nameInput}
|
||
class="name-edit name-edit-dark"
|
||
onkeydown={async (e: KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
editingName = false;
|
||
if (nameInput && nameInput !== memberName) {
|
||
const old = memberName;
|
||
memberName = nameInput;
|
||
try {
|
||
const res = await fetch('/api/members', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: nameInput })
|
||
});
|
||
if (!res.ok) memberName = old;
|
||
} catch {
|
||
memberName = old;
|
||
}
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
editingName = false;
|
||
nameInput = memberName;
|
||
}
|
||
}}
|
||
onblur={() => {
|
||
editingName = false;
|
||
nameInput = memberName;
|
||
}}
|
||
autofocus
|
||
/>
|
||
{:else}
|
||
<span
|
||
class="hero-name"
|
||
role="button"
|
||
tabindex="0"
|
||
onclick={() => {
|
||
nameInput = memberName;
|
||
editingName = true;
|
||
}}
|
||
onkeydown={(e) =>
|
||
e.key === 'Enter' && ((nameInput = memberName), (editingName = true))}
|
||
>
|
||
{memberName || username}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
<div class="hero-date">
|
||
<span class="hero-weekday">{headWeekday()}</span>
|
||
<span class="hero-day">{headDayOrdinal()}</span>
|
||
<span class="hero-month">{headMonth()}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{#if showColorPicker}
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div class="color-picker-overlay" onclick={() => (showColorPicker = false)}>
|
||
<div class="color-picker-popup" onclick={(e) => e.stopPropagation()}>
|
||
<p>Pick a color:</p>
|
||
<div class="color-swatches">
|
||
{#each ['#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#78716c'] as color}
|
||
<button
|
||
class="swatch"
|
||
style="background:{color}"
|
||
aria-label={color}
|
||
onclick={async () => {
|
||
const old = memberColor;
|
||
memberColor = color;
|
||
showColorPicker = false;
|
||
try {
|
||
const res = await fetch('/api/members', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ color })
|
||
});
|
||
if (!res.ok) memberColor = old;
|
||
} catch {
|
||
memberColor = old;
|
||
}
|
||
}}
|
||
></button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="hero-progress">
|
||
<span class="hero-pct">{weeklyTotal > 0 ? weeklyPct : 0}%</span>
|
||
<div class="hero-bars">
|
||
<div class="bar-wrap hero-bar">
|
||
{#if pmTargetPct != null}
|
||
<div class="hero-target-fill" style="width:{pmTargetPct}%"></div>
|
||
{/if}
|
||
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div>
|
||
{#if pmTargetPct != null}
|
||
<span class="hero-target-lbl" style="left:{pmTargetPct}%"
|
||
>{@html targetIcon} {pmTargetPct}%</span
|
||
>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<span class="hero-count">
|
||
{weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="hero-meta">
|
||
<div class="hero-bonus-slot">
|
||
{#if anyWeekBonuses && weekBonusTallies.length > 0}
|
||
<div class="hero-pills">
|
||
{#each weekBonusTallies as t}
|
||
<span class="hero-pill">🎉 {t.name} ×{t.count}</span>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{#if isCurrentWeek}
|
||
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
|
||
{:else}
|
||
<button class="hero-now" onclick={jumpToNow}><span>← Back to this week</span></button>
|
||
{/if}
|
||
{#if inYesterday}
|
||
<button class="hero-now hero-yesterday-on" onclick={() => (viewDate = 'today')}>
|
||
<span>← Back to today</span>
|
||
</button>
|
||
{:else if yesterdayAvailable}
|
||
<button class="hero-now hero-yesterday" onclick={() => (viewDate = 'yesterday')}>
|
||
<span>Forgot to mark a chore yesterday?</span>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
<nav class="hero-nav">
|
||
<button class="hero-nav-btn" onclick={() => navigate(-1)} aria-label="Previous week"
|
||
>{@html chevronLeftIcon}</button
|
||
>
|
||
<button class="hero-nav-btn cur" onclick={jumpToNow}
|
||
>{isCurrentWeek ? 'This week' : `${shortDate(weekStart)}`}</button
|
||
>
|
||
<button class="hero-nav-btn" onclick={() => navigate(1)} aria-label="Next week"
|
||
>{@html chevronRightIcon}</button
|
||
>
|
||
</nav>
|
||
</header>
|
||
|
||
<!-- WEEK-TO-DATE TILES -->
|
||
<div class="tiles">
|
||
<div class="tile tile-cash">
|
||
<span class="tile-val">£{weekCashTotal.toFixed(2)}</span>
|
||
<span class="tile-lbl">cash this week</span>
|
||
</div>
|
||
<div class="tile tile-pts">
|
||
<span class="tile-val">{weekChorePoints}</span>
|
||
<span class="tile-lbl">points this week</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GOALS (threshold bonus configs) -->
|
||
{#if thresholdGoals.length > 0}
|
||
<div class="goals">
|
||
{#each thresholdGoals as goal}
|
||
{@const pct = goal.pct}
|
||
<div class="goal-card" class:goal-achieved={goal.achieved}>
|
||
<div class="goal-head">
|
||
<span class="goal-name">{goal.config.name}</span>
|
||
<span class="goal-reward">
|
||
{#if goal.config.rewardType === 'cash'}
|
||
£{Number(goal.config.rewardValue).toFixed(2)}
|
||
{:else if goal.config.rewardType === 'prize'}
|
||
{@html giftIcon}
|
||
{:else}
|
||
{goal.config.rewardValue} pts
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
{#if goal.achieved}
|
||
<div class="goal-done">
|
||
<span class="goal-tick">{@html checkCircleIcon}</span>
|
||
<span class="goal-done-text">
|
||
<b>Reward earned</b>
|
||
<span>{goalRewardLabel(goal)}</span>
|
||
</span>
|
||
</div>
|
||
{:else}
|
||
<div class="goal-bar-wrap">
|
||
<div class="goal-bar-fill" style="width:{pct}%"></div>
|
||
{#if goal.targetPct < 100}
|
||
<div class="goal-target" style="left:{goal.targetPct}%"></div>
|
||
{/if}
|
||
</div>
|
||
<div class="goal-foot">
|
||
{#if goal.isPercent && goal.totalPotential === 0}
|
||
<span class="goal-progress muted">no chores set</span>
|
||
{:else if goal.isPercent}
|
||
<span class="goal-progress"
|
||
>{Math.min(goal.current, goal.totalPotential)} / {goal.totalPotential}</span
|
||
>
|
||
{:else}
|
||
<span class="goal-progress"
|
||
>{Math.min(goal.current, goal.target)} / {goal.target}</span
|
||
>
|
||
{/if}
|
||
<span class="goal-pct">{pct}%</span>
|
||
<span class="goal-target-pill"
|
||
>{@html targetIcon}
|
||
{goal.targetLabel}{#if goal.targetChoreName}
|
||
<span class="goal-chore"> · {goal.targetChoreName}</span>
|
||
{/if}</span
|
||
>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- KANBAN -->
|
||
{#if accessDisabled}
|
||
<div class="kanban locked">
|
||
<div class="column col-daily">
|
||
<h2>{@html sunIcon} Daily</h2>
|
||
<p class="empty">{@html lockIcon} Access paused</p>
|
||
</div>
|
||
<div class="column col-weekly">
|
||
<h2>{@html calendarIcon} Weekly</h2>
|
||
<p class="empty">{@html lockIcon}</p>
|
||
</div>
|
||
<div class="column col-done">
|
||
<h2>{@html checkCircleIcon} Done</h2>
|
||
<p class="empty">{@html lockIcon}</p>
|
||
</div>
|
||
</div>
|
||
{:else}
|
||
{#if celebrations.length > 0 || goalCelebrations.length > 0}
|
||
<div class="celebrations">
|
||
{#each celebrations as cel}
|
||
<div class="celebrate-card">
|
||
<span class="celebrate-emoji">{cel.emoji}</span>
|
||
<span class="celebrate-text"
|
||
><strong>{cel.name}</strong> complete — amazing work!</span
|
||
>
|
||
<button class="celebrate-close" onclick={() => dismissCelebrate(cel.id)}>✕</button>
|
||
</div>
|
||
{/each}
|
||
{#each goalCelebrations as cel}
|
||
<div class="celebrate-card goal">
|
||
<span class="celebrate-emoji">🎉</span>
|
||
<span class="celebrate-text"
|
||
><strong>{cel.name}</strong> complete — you earned <b>{cel.reward}</b>!</span
|
||
>
|
||
<button class="celebrate-close" onclick={() => dismissGoalCelebrate(cel.id)}>✕</button
|
||
>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{#if inYesterday}
|
||
<div class="yesterday-banner">
|
||
<span>📝 Showing <b>yesterday</b> — tap anything you missed. Undo lives on today's view.</span>
|
||
<button class="yesterday-back" onclick={() => (viewDate = 'today')}>Back to today →</button>
|
||
</div>
|
||
{/if}
|
||
<div class="kanban">
|
||
<div class="column col-daily">
|
||
<h2>{@html sunIcon} Daily ({dailyPending.length})</h2>
|
||
{#if dailyPending.length === 0}
|
||
<p class="empty">All done!</p>
|
||
{:else}
|
||
{#each dailyPending as chore}
|
||
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
|
||
<span class="checkbox">{@html circleIcon}</span>
|
||
<span class="chore-name">{choreName(chore)}</span>
|
||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
<div class="column col-weekly">
|
||
<h2>
|
||
{@html calendarIcon} Weekly ({weeklyPending.length +
|
||
memberTodos.filter((t) => !isTodoDone(t.id)).length})
|
||
</h2>
|
||
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isTodoDone(t.id)).length === 0}
|
||
<p class="empty">All done!</p>
|
||
{:else}
|
||
{#each weeklyPending as chore}
|
||
<button class="chore" onclick={() => toggle(chore)} disabled={togglingIds.has(chore.id)}>
|
||
<span class="checkbox">{@html circleIcon}</span>
|
||
<span class="chore-name">{choreName(chore)}</span>
|
||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||
</button>
|
||
{/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"
|
||
class:todo-blue={urgency === 'blue'}
|
||
class:todo-green={urgency === 'green'}
|
||
class:todo-orange={urgency === 'orange'}
|
||
class:todo-red={urgency === 'red'}
|
||
class:todo-black={urgency === 'black'}
|
||
class:todo-expired={expired}
|
||
onclick={() => toggle(todo)}
|
||
disabled={togglingIds.has(todo.id) || expired}
|
||
>
|
||
<span class="checkbox">{@html circleIcon}</span>
|
||
<span class="todo-main">
|
||
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||
{#if todo.completeBy}
|
||
{#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>
|
||
{/if}
|
||
{/if}
|
||
</span>
|
||
<span class="todo-label">todo</span>
|
||
<span class="chore-value">{todo.emoji || '🎯'}</span>
|
||
</button>
|
||
{:else}
|
||
<button
|
||
class="chore todo-chore"
|
||
class:todo-blue={urgency === 'blue'}
|
||
class:todo-green={urgency === 'green'}
|
||
class:todo-orange={urgency === 'orange'}
|
||
class:todo-red={urgency === 'red'}
|
||
class:todo-black={urgency === 'black'}
|
||
class:todo-expired={expired}
|
||
onclick={() => toggle(todo)}
|
||
disabled={togglingIds.has(todo.id) || expired}
|
||
>
|
||
<span class="checkbox">{@html circleIcon}</span>
|
||
<span class="todo-main">
|
||
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||
{#if todo.completeBy}
|
||
{#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>
|
||
{/if}
|
||
{/if}
|
||
</span>
|
||
<span class="todo-label">todo</span>
|
||
<span class="chore-value">
|
||
{todo.type === 'money'
|
||
? `£${Number(todo.value).toFixed(2)}`
|
||
: `${todo.value} pts`}
|
||
</span>
|
||
</button>
|
||
{/if}
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
<div class="column col-done">
|
||
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
|
||
{#if completedToday.length === 0}
|
||
<p class="empty">Nothing yet</p>
|
||
{:else}
|
||
{#each completedToday as c}
|
||
{@const chore = assigned.find((a) => a.id === c.assignedChoreId)}
|
||
{#if chore}
|
||
<!-- Yesterday view is mark-only: un-doing a backdated
|
||
completion would toggle against this week's window
|
||
and create confusing duplicates, so undo stays on today. -->
|
||
<button
|
||
class="chore done"
|
||
onclick={() => !inYesterday && toggle(chore)}
|
||
disabled={togglingIds.has(chore.id) || inYesterday}
|
||
title={inYesterday ? 'Go back to today to undo' : undefined}
|
||
>
|
||
<span class="checkbox">{@html checkCircleIcon}</span>
|
||
<span class="chore-name">{choreName(chore)}</span>
|
||
{#if chore.type === 'emoji'}
|
||
<span class="chore-value">{chore.emoji || '🎯'}</span>
|
||
{:else}
|
||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||
{/if}
|
||
</button>
|
||
{/if}
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- WALLET / CLAIMS -->
|
||
<div class="wallet">
|
||
<div class="wallet-top">
|
||
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
||
<!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
|
||
<div class="life-badges">
|
||
<span class="life-badge life-cash">£{allTimeCash.toFixed(2)} <i>all time</i></span>
|
||
<span class="life-badge life-pts">{allTimePoints.toLocaleString()} <i>pts</i></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wallet-sub">
|
||
<span class="wallet-sub-lbl">{@html clockIcon} To collect from parent</span>
|
||
</div>
|
||
{#if pendingRewards.length === 0}
|
||
<p class="wallet-empty">Nothing waiting — you're all caught up</p>
|
||
{:else}
|
||
<div class="wallet-rewards">
|
||
{#each pendingRewards as r}
|
||
{@const isReq = r.status === 'requested'}
|
||
<div class="wallet-reward">
|
||
<span class="wr-label">{r.label}</span>
|
||
<span class="wr-val">
|
||
{#if r.rewardType === 'cash'}
|
||
£{Number(r.value).toFixed(2)}
|
||
{:else if r.rewardType === 'prize'}
|
||
{@html giftIcon}
|
||
{:else}
|
||
{r.value} pts
|
||
{/if}
|
||
</span>
|
||
{#if r.rewardType === 'points'}
|
||
<span class="wr-pending">{@html checkCircleIcon} credited</span>
|
||
{:else if isReq}
|
||
<span class="wr-pending">{@html clockIcon} waiting</span>
|
||
{:else if paydayLocked(r)}
|
||
<span class="wr-pending wr-locked"
|
||
>{@html lockIcon} pays out {formatHumanDate(r.settleDate)}</span
|
||
>
|
||
{:else}
|
||
<button
|
||
class="wr-cta"
|
||
onclick={async () => {
|
||
try {
|
||
await memberApi.claimReward(famId, r.id);
|
||
} catch (e) {
|
||
claimError = e instanceof Error ? e.message : 'Claim failed';
|
||
}
|
||
}}>Request</button
|
||
>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
|
||
<style>
|
||
.toast {
|
||
position: fixed;
|
||
top: 3.5rem;
|
||
right: 1.5rem;
|
||
background: #dc2626;
|
||
color: white;
|
||
padding: 0.6rem 1rem;
|
||
border-radius: 8px;
|
||
font-size: 0.85rem;
|
||
z-index: 999;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
animation: fadein 0.2s;
|
||
}
|
||
@keyframes fadein {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.empty {
|
||
color: #9ca3af;
|
||
font-size: 0.85rem;
|
||
text-align: center;
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.member-card {
|
||
border: 1px solid #e5e7eb;
|
||
border-left: 4px solid #6366f1;
|
||
border-radius: 10px;
|
||
padding: 0.65rem 0.85rem;
|
||
background: #f6fbff;
|
||
margin-bottom: 0.6rem;
|
||
transition:
|
||
transform 0.15s,
|
||
box-shadow 0.15s,
|
||
border-color 0.15s;
|
||
}
|
||
.member-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
|
||
border-color: #c7d2fe;
|
||
}
|
||
.member-card:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
.dot {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.member-name {
|
||
font-weight: 600;
|
||
flex: 1;
|
||
font-size: 0.9rem;
|
||
}
|
||
.link {
|
||
font-size: 0.8rem;
|
||
color: #6366f1;
|
||
text-decoration: none;
|
||
}
|
||
.pocket-row {
|
||
font-size: 0.78rem;
|
||
color: #6b7280;
|
||
margin-top: 0.3rem;
|
||
}
|
||
.pocket-row .unset {
|
||
color: #b45309;
|
||
font-weight: 600;
|
||
}
|
||
.stats {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
font-size: 0.8rem;
|
||
color: #6b7280;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
.progress-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
.label {
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
min-width: 2.5rem;
|
||
}
|
||
.bar-wrap {
|
||
flex: 1;
|
||
height: 8px;
|
||
background: #e5e7eb;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
.bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||
border-radius: 4px;
|
||
transition: width 0.3s;
|
||
}
|
||
.count {
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
min-width: 3rem;
|
||
text-align: right;
|
||
}
|
||
.done-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0.4rem 0 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
}
|
||
.done-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.4rem 0.5rem;
|
||
border-radius: 6px;
|
||
font-size: 0.8rem;
|
||
color: #059669;
|
||
}
|
||
.done-item.odd {
|
||
background: #ffffff;
|
||
}
|
||
.done-icon {
|
||
display: inline-flex;
|
||
color: #22c55e;
|
||
flex-shrink: 0;
|
||
}
|
||
.done-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.revoke-form {
|
||
margin-left: auto;
|
||
display: inline-flex;
|
||
flex-shrink: 0;
|
||
}
|
||
.revoke-btn {
|
||
background: none;
|
||
border: 1px solid transparent;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
padding: 0 2px;
|
||
font-size: 0.75rem;
|
||
line-height: 1.2;
|
||
color: #9ca3af;
|
||
}
|
||
.revoke-btn:hover {
|
||
color: #dc2626;
|
||
border-color: #fca5a5;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.trigger-card {
|
||
border: 1px solid #e5e7eb;
|
||
border-left: 3px solid #22c55e;
|
||
border-radius: 10px;
|
||
padding: 0.65rem 0.85rem;
|
||
background: #f0fdf4;
|
||
margin-bottom: 0.6rem;
|
||
transition:
|
||
transform 0.15s,
|
||
box-shadow 0.15s;
|
||
}
|
||
.trigger-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.trigger-card:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.trigger-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
.trigger-name {
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
}
|
||
.trigger-value {
|
||
font-size: 0.85rem;
|
||
color: #059669;
|
||
font-weight: 600;
|
||
}
|
||
.trigger-row {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
}
|
||
.trigger-select {
|
||
flex: 1;
|
||
padding: 0.35rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.member-claims {
|
||
margin-bottom: 1rem;
|
||
padding: 0.75rem;
|
||
background: #fafafa;
|
||
border: 1px solid #f1f1f3;
|
||
border-radius: 10px;
|
||
}
|
||
.member-claims:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.member-claims-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.member-claims-head .dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.member-name {
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
}
|
||
.member-owed {
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
color: #2563eb;
|
||
background: #eff6ff;
|
||
border: 1px solid #dbeafe;
|
||
padding: 0.1rem 0.45rem;
|
||
border-radius: 999px;
|
||
}
|
||
.issue-all-form {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
.section-label {
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
color: #9ca3af;
|
||
margin: 0.6rem 0 0.3rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.claim-item {
|
||
margin-bottom: 0.3rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.8rem;
|
||
padding: 0.4rem 0.5rem;
|
||
background: #fff;
|
||
border: 1px solid #eef0f3;
|
||
border-radius: 8px;
|
||
}
|
||
.claim-item.outstanding {
|
||
background: #f8f9fb;
|
||
opacity: 0.75;
|
||
}
|
||
.claim-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #f59e0b;
|
||
flex-shrink: 0;
|
||
}
|
||
.claim-label {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.claim-value {
|
||
font-weight: 600;
|
||
color: #059669;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.owe-locked {
|
||
font-weight: 500;
|
||
color: #9ca3af;
|
||
font-size: 0.72rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
}
|
||
.claim-item :global(button) {
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
.claims-summary {
|
||
display: flex;
|
||
gap: 0.6rem;
|
||
margin-bottom: 0.9rem;
|
||
}
|
||
.claims-stat {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.55rem;
|
||
padding: 0.6rem 0.7rem;
|
||
background: linear-gradient(135deg, #fffbeb, #fff);
|
||
border: 1px solid #fde68a;
|
||
border-radius: 10px;
|
||
}
|
||
.claims-stat.pending {
|
||
background: linear-gradient(135deg, #eef2ff, #fff);
|
||
border-color: #c7d2fe;
|
||
}
|
||
.claims-stat-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 8px;
|
||
background: #f59e0b;
|
||
color: #fff;
|
||
flex-shrink: 0;
|
||
}
|
||
.claims-stat-icon.pending {
|
||
background: #6366f1;
|
||
}
|
||
.claims-stat-value {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
line-height: 1.1;
|
||
color: #1f2937;
|
||
}
|
||
.claims-stat-label {
|
||
font-size: 0.68rem;
|
||
color: #6b7280;
|
||
}
|
||
.claims-stat.pending .claims-stat-label {
|
||
color: #4338ca;
|
||
}
|
||
.claims-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
color: #059669;
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
padding: 0.5rem 0;
|
||
}
|
||
.error {
|
||
color: #dc2626;
|
||
padding: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.color-dot {
|
||
display: inline-block;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
border: 2px solid #e5e7eb;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
.name-edit {
|
||
font-size: 1.2rem;
|
||
padding: 0.1rem 0.3rem;
|
||
border: 1px solid #6366f1;
|
||
border-radius: 4px;
|
||
outline: none;
|
||
font-weight: 700;
|
||
}
|
||
.color-picker-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
}
|
||
.color-picker-popup {
|
||
background: white;
|
||
padding: 1.25rem;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||
}
|
||
.color-picker-popup p {
|
||
margin: 0 0 0.75rem;
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
}
|
||
.color-swatches {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.swatch {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
border: 2px solid #e5e7eb;
|
||
cursor: pointer;
|
||
}
|
||
.swatch:hover {
|
||
border-color: #6366f1;
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
/* ── Child view ── */
|
||
|
||
.hero {
|
||
background: linear-gradient(135deg, #6366f1, #8b5cf6 55%, #a855f7);
|
||
border-radius: 16px;
|
||
padding: 1.25rem 1.5rem;
|
||
margin-bottom: 1rem;
|
||
color: #fff;
|
||
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
/* Week-activity line effect behind the hero content — no axes, just a
|
||
blurred white trace with dots. Pure decoration, ignored by AT. */
|
||
.hero-bg {
|
||
position: absolute;
|
||
inset: auto 0 0 0;
|
||
width: 100%;
|
||
height: 120px;
|
||
opacity: 0.45;
|
||
filter: blur(5px);
|
||
pointer-events: none;
|
||
}
|
||
.hero-top,
|
||
.hero-progress,
|
||
.hero-meta,
|
||
.hero-nav {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.hero-now {
|
||
/* Same full-width centered slot as .hero-days so the hero keeps its height */
|
||
flex: 1 1 100%;
|
||
margin-top: 0.25rem;
|
||
min-height: 2.6rem;
|
||
align-content: center;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.hero-now span {
|
||
display: inline-block;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: #4338ca;
|
||
border-radius: 999px;
|
||
padding: 0.45rem 1.1rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 800;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||
}
|
||
.hero-now:hover span {
|
||
background: #fff;
|
||
}
|
||
.hero-now.hero-yesterday span {
|
||
background: rgba(255, 255, 255, 0.22);
|
||
color: #fff;
|
||
border: 1px dashed rgba(255, 255, 255, 0.7);
|
||
box-shadow: none;
|
||
font-weight: 700;
|
||
}
|
||
.hero-now.hero-yesterday:hover span {
|
||
background: rgba(255, 255, 255, 0.32);
|
||
}
|
||
.yesterday-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
background: #fef3c7;
|
||
border: 1px solid #fcd34d;
|
||
color: #92400e;
|
||
border-radius: 10px;
|
||
padding: 0.5rem 0.85rem;
|
||
font-size: 0.82rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.yesterday-back {
|
||
background: none;
|
||
border: none;
|
||
color: #b45309;
|
||
font-weight: 800;
|
||
font-size: 0.82rem;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.yesterday-back:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.yesterday-tag {
|
||
font-size: 0.62rem;
|
||
font-weight: 800;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: #b45309;
|
||
background: #fef3c7;
|
||
border-radius: 999px;
|
||
padding: 0.1rem 0.45rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.hero-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.hero-id {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
min-width: 0;
|
||
}
|
||
.hero-dot {
|
||
border-color: rgba(255, 255, 255, 0.6);
|
||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
|
||
}
|
||
.hero-name {
|
||
font-size: 1.5rem;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
border-bottom: 1px dashed rgba(255, 255, 255, 0.4);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.name-edit-dark {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: #1e1b4b;
|
||
border-color: #fff;
|
||
font-size: 1.2rem;
|
||
}
|
||
.hero-date {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.45rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.hero-day {
|
||
font-size: 1.7rem;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
color: #fff;
|
||
}
|
||
.hero-month {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
}
|
||
.hero-weekday {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
}
|
||
.hero-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.7rem;
|
||
}
|
||
.hero-pct {
|
||
font-size: 1.5rem;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
flex-shrink: 0;
|
||
min-width: 3.4rem;
|
||
}
|
||
.hero-bar {
|
||
flex: 1;
|
||
height: 16px;
|
||
background: rgba(255, 255, 255, 0.28);
|
||
border-radius: 8px;
|
||
position: relative;
|
||
}
|
||
.hero-fill {
|
||
position: relative;
|
||
z-index: 2;
|
||
height: 100%;
|
||
background: #c7d2fe;
|
||
border-radius: 8px;
|
||
}
|
||
.hero-bars {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.hero-target-fill {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
bottom: 0;
|
||
z-index: 1;
|
||
background: rgba(253, 224, 71, 0.45);
|
||
border-radius: 8px;
|
||
}
|
||
.hero-target-lbl {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: 100%;
|
||
transform: translateX(-50%);
|
||
margin-bottom: 4px;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
color: #fde047;
|
||
white-space: nowrap;
|
||
z-index: 4;
|
||
}
|
||
.hero-count {
|
||
font-size: 0.8rem;
|
||
font-weight: 700;
|
||
color: rgba(255, 255, 255, 0.95);
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
}
|
||
.hero-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.9rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
/* Fixed slot for the pills / no-bonus line so week navigation never
|
||
moves the hero when the bonus content changes height. Fits two rows
|
||
of pills; more than that still grows gracefully. */
|
||
.hero-bonus-slot {
|
||
flex: 1 1 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 3.4rem;
|
||
align-content: center;
|
||
}
|
||
.hero-pills {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.hero-pill {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
color: #fff;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
}
|
||
.hero-days {
|
||
font-size: 1rem;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
/* Own full-width line, centered under the hero content */
|
||
flex: 1 1 100%;
|
||
text-align: center;
|
||
margin-top: 0.25rem;
|
||
/* Fixed slot so swapping in the back-to-this-week CTA never moves the hero */
|
||
min-height: 2.6rem;
|
||
align-content: center;
|
||
}
|
||
.hero-days b {
|
||
font-size: 1.5rem;
|
||
margin-right: 0.15rem;
|
||
}
|
||
.hero-nav {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
}
|
||
.hero-nav-btn {
|
||
font-size: 0.85rem;
|
||
font-weight: 700;
|
||
padding: 0.35rem 0.9rem;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
color: #fff;
|
||
cursor: pointer;
|
||
}
|
||
.hero-nav-btn:hover {
|
||
background: rgba(255, 255, 255, 0.32);
|
||
}
|
||
.hero-nav-btn.cur {
|
||
flex: 1;
|
||
background: transparent;
|
||
color: #fff;
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
}
|
||
|
||
/* ── Kanban scroll for parent view cards ── */
|
||
.kanban-scroll {
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.kanban-inner {
|
||
min-width: 450px;
|
||
}
|
||
|
||
.tiles {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.tile {
|
||
border-radius: 14px;
|
||
padding: 0.9rem 1.1rem;
|
||
color: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||
}
|
||
.tile-cash {
|
||
background: linear-gradient(135deg, #059669, #10b981);
|
||
}
|
||
.tile-pts {
|
||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||
}
|
||
.tile-members {
|
||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||
}
|
||
.tile-chores {
|
||
background: linear-gradient(135deg, #0284c7, #38bdf8);
|
||
}
|
||
.tile-val {
|
||
font-size: 1.6rem;
|
||
font-weight: 800;
|
||
}
|
||
.tile-lbl {
|
||
font-size: 0.75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
opacity: 0.9;
|
||
}
|
||
|
||
/* ── Goals (threshold bonus progress) ── */
|
||
.goals {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 0.6rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.goals {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
.goal-card {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-left: 4px solid #6366f1;
|
||
border-radius: 10px;
|
||
padding: 0.75rem 1rem;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.goal-achieved {
|
||
border-left-color: #10b981;
|
||
background: #f0fdf4;
|
||
}
|
||
.goal-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 0.4rem;
|
||
}
|
||
.goal-name {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
.goal-reward {
|
||
font-size: 1.3rem;
|
||
font-weight: 800;
|
||
color: #6366f1;
|
||
white-space: nowrap;
|
||
}
|
||
.goal-achieved .goal-reward {
|
||
color: #059669;
|
||
}
|
||
.goal-bar-wrap {
|
||
position: relative;
|
||
height: 8px;
|
||
background: #f3f4f6;
|
||
border-radius: 4px;
|
||
overflow: visible;
|
||
margin: 0.35rem 0 0.5rem;
|
||
}
|
||
.goal-bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
max-width: 100%;
|
||
}
|
||
.goal-target {
|
||
position: absolute;
|
||
top: -4px;
|
||
bottom: -4px;
|
||
width: 3px;
|
||
background: #10b981;
|
||
border-radius: 2px;
|
||
transform: translateX(-50%);
|
||
z-index: 2;
|
||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8);
|
||
}
|
||
.goal-target-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
font-size: 1rem;
|
||
font-weight: 800;
|
||
color: #059669;
|
||
background: #f0fdf4;
|
||
border: 1px solid #bbf7d0;
|
||
border-radius: 999px;
|
||
padding: 3px 12px;
|
||
white-space: nowrap;
|
||
}
|
||
.goal-target-pill :global(svg) {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
.goal-chore {
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
}
|
||
.goal-achieved .goal-bar-fill {
|
||
background: linear-gradient(90deg, #10b981, #34d399);
|
||
}
|
||
.goal-foot {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.goal-progress {
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
font-weight: 600;
|
||
}
|
||
.goal-badge {
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
color: #059669;
|
||
}
|
||
.goal-pct {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #6366f1;
|
||
}
|
||
|
||
/* Achieved goal — big tick + reward, progress hidden */
|
||
.goal-done {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
padding: 0.5rem 0 0.15rem;
|
||
}
|
||
.goal-tick {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
background: #10b981;
|
||
color: #fff;
|
||
flex: none;
|
||
}
|
||
.goal-tick :global(svg) {
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
.goal-done-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
font-size: 0.85rem;
|
||
color: #047857;
|
||
}
|
||
.goal-done-text b {
|
||
font-size: 0.9rem;
|
||
}
|
||
.goal-done-text span {
|
||
font-weight: 800;
|
||
color: #059669;
|
||
font-size: 1rem;
|
||
}
|
||
.goal-achieved .goal-done {
|
||
animation: goal-pop 0.35s ease;
|
||
}
|
||
@keyframes goal-pop {
|
||
0% {
|
||
transform: scale(0.9);
|
||
opacity: 0;
|
||
}
|
||
100% {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* Admin overview — "Goals completed" mini cards */
|
||
.goals-completed {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.goal-mini-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 0.6rem;
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.goal-mini-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
.goal-mini {
|
||
background: linear-gradient(135deg, #d1d3fa, #ccfbf1);
|
||
border: 1px solid #a7f3d0;
|
||
border-radius: 12px;
|
||
padding: 0.55rem 0.7rem;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
min-width: 0;
|
||
/* Own width as a query container, so tight cards drop the member name */
|
||
container-type: inline-size;
|
||
}
|
||
/* Card narrower than 250px: hide the member name so the row doesn't squish */
|
||
@container (max-width: 249px) {
|
||
.gm-member {
|
||
display: none;
|
||
}
|
||
}
|
||
.gm-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
min-width: 0;
|
||
}
|
||
.gm-top .dot {
|
||
flex: none;
|
||
}
|
||
.gm-name {
|
||
font-weight: 700;
|
||
font-size: 0.82rem;
|
||
color: #7a43bf;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 1;
|
||
}
|
||
.gm-member {
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
flex: none;
|
||
}
|
||
.gm-bottom {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 0.4rem;
|
||
white-space: nowrap;
|
||
}
|
||
.gm-reward {
|
||
font-weight: 800;
|
||
color: #047857;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
/* Goal celebration notice (child view) */
|
||
.celebrate-card.goal {
|
||
border-color: #86efac;
|
||
background: #ecfdf5;
|
||
}
|
||
|
||
/* ── Kanban ── */
|
||
.celebrations {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.celebrate-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
background: linear-gradient(90deg, #fef3c7, #fce7f3);
|
||
border: 1px solid #fcd34d;
|
||
border-radius: 12px;
|
||
padding: 0.55rem 0.85rem;
|
||
animation: celebrate-pop 0.35s ease-out;
|
||
}
|
||
@keyframes celebrate-pop {
|
||
from {
|
||
transform: scale(0.9);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
.celebrate-emoji {
|
||
font-size: 1.6rem;
|
||
}
|
||
.celebrate-text {
|
||
flex: 1;
|
||
font-size: 0.85rem;
|
||
color: #78350f;
|
||
}
|
||
.celebrate-close {
|
||
background: none;
|
||
border: none;
|
||
color: #92400e;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
padding: 0.15rem;
|
||
}
|
||
.kanban {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr 1fr;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.column {
|
||
border: 1px solid #e5e7eb;
|
||
border-top: 4px solid #6366f1;
|
||
border-radius: 12px;
|
||
padding: 0.75rem;
|
||
background: #fafbff;
|
||
}
|
||
.column h2 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.95rem;
|
||
}
|
||
.col-daily {
|
||
border-top-color: #6366f1;
|
||
background: #eef2ff;
|
||
}
|
||
.col-weekly {
|
||
border-top-color: #f59e0b;
|
||
background: #fffbeb;
|
||
}
|
||
.col-done {
|
||
border-top-color: #10b981;
|
||
background: #ecfdf5;
|
||
}
|
||
|
||
.chore {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
width: 100%;
|
||
padding: 0.5rem 0.6rem;
|
||
margin-bottom: 0.4rem;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
background: white;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
font-size: 0.85rem;
|
||
}
|
||
.chore:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08);
|
||
border-color: #c7d2fe;
|
||
}
|
||
|
||
/* ── Todo traffic light borders ── */
|
||
.chore.todo-chore {
|
||
border-left: 4px solid #3b82f6;
|
||
}
|
||
.chore.todo-blue {
|
||
border-left-color: #3b82f6;
|
||
}
|
||
.chore.todo-green {
|
||
border-left-color: #22c55e;
|
||
}
|
||
.chore.todo-orange {
|
||
border-left-color: #f97316;
|
||
}
|
||
.chore.todo-red {
|
||
border-left-color: #ef4444;
|
||
}
|
||
.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;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.03em;
|
||
background: #fef3c7;
|
||
color: #b45309;
|
||
border: 1px solid #fcd34d;
|
||
border-radius: 4px;
|
||
padding: 1px 5px;
|
||
flex-shrink: 0;
|
||
}
|
||
.todo-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-width: 0;
|
||
gap: 1px;
|
||
}
|
||
.todo-due {
|
||
font-size: 0.68rem;
|
||
color: #6b7280;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.todo-due.due-today {
|
||
color: #dc2626;
|
||
font-weight: 700;
|
||
letter-spacing: 0.03em;
|
||
animation: due-today-pulse 1.4s ease-in-out infinite;
|
||
}
|
||
@keyframes due-today-pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.55;
|
||
}
|
||
}
|
||
.chore:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
.done {
|
||
opacity: 0.7;
|
||
}
|
||
.checkbox {
|
||
font-size: 1rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.checkbox :global(svg) {
|
||
width: 1.15em;
|
||
height: 1.15em;
|
||
display: inline-block;
|
||
vertical-align: -0.2em;
|
||
}
|
||
.chore-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.chore-value {
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.wallet {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 14px;
|
||
padding: 1rem 1.25rem;
|
||
}
|
||
.wallet-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.wallet-lbl {
|
||
font-size: 0.8rem;
|
||
font-weight: 800;
|
||
color: #374151;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.wallet-lbl :global(svg) {
|
||
width: 1em;
|
||
height: 1em;
|
||
vertical-align: -0.15em;
|
||
margin-right: 0.3em;
|
||
}
|
||
.life-badges {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
.life-badge {
|
||
display: inline-flex;
|
||
align-items: baseline;
|
||
gap: 0.3rem;
|
||
font-size: 0.8rem;
|
||
font-weight: 800;
|
||
padding: 3px 11px;
|
||
border-radius: 999px;
|
||
color: #fff;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||
}
|
||
.life-badge i {
|
||
font-style: normal;
|
||
font-size: 0.65rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
opacity: 0.9;
|
||
}
|
||
.life-cash {
|
||
background: linear-gradient(135deg, #059669, #10b981);
|
||
}
|
||
.life-pts {
|
||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||
}
|
||
.wallet-sub {
|
||
border-top: 1px solid #eef0f2;
|
||
padding-top: 0.6rem;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
.wallet-sub-lbl {
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.wallet-sub-lbl :global(svg) {
|
||
width: 1em;
|
||
height: 1em;
|
||
vertical-align: -0.15em;
|
||
margin-right: 0.3em;
|
||
}
|
||
.wallet-empty {
|
||
margin: 0;
|
||
font-size: 0.85rem;
|
||
color: #9ca3af;
|
||
}
|
||
.wallet-rewards {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
.wallet-reward {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
padding: 0.6rem 0.8rem;
|
||
border-radius: 10px;
|
||
background: #fafbfc;
|
||
border: 1px solid #eeeff2;
|
||
}
|
||
.wr-label {
|
||
flex: 1;
|
||
font-weight: 600;
|
||
font-size: 0.85rem;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.wr-val {
|
||
font-weight: 800;
|
||
color: #059669;
|
||
}
|
||
.wr-pending {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
}
|
||
.wr-pending :global(svg) {
|
||
width: 0.95em;
|
||
height: 0.95em;
|
||
vertical-align: -0.15em;
|
||
margin-right: 0.25em;
|
||
}
|
||
.wr-locked {
|
||
color: #d97706;
|
||
}
|
||
.owe-locked {
|
||
margin-left: 0.35rem;
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
color: #d97706;
|
||
}
|
||
.wr-cta {
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
padding: 3px 12px;
|
||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
}
|
||
.wr-cta:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.eow-mode-on {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin: 0.6rem 0;
|
||
padding: 0.6rem 0.8rem;
|
||
border: 1px dashed #f59e0b;
|
||
border-radius: 8px;
|
||
background: #fffbeb;
|
||
color: #92400e;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.payday-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
margin-bottom: 1rem;
|
||
padding: 0.85rem 1.1rem;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||
color: #451a03;
|
||
font-weight: 600;
|
||
font-size: 1.05rem;
|
||
box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
|
||
}
|
||
.payday-banner b {
|
||
font-weight: 800;
|
||
}
|
||
|
||
.payday-countdown {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.9rem;
|
||
margin-bottom: 1rem;
|
||
padding: 1rem 1.2rem;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||
color: #451a03;
|
||
box-shadow: 0 6px 16px rgba(245, 158, 11, 0.35);
|
||
}
|
||
.pd-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.pd-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
}
|
||
.pd-title {
|
||
margin: 0;
|
||
font-weight: 700;
|
||
font-size: 0.95rem;
|
||
opacity: 0.9;
|
||
}
|
||
.pd-num {
|
||
margin: 0;
|
||
font-size: 1.7rem;
|
||
font-weight: 800;
|
||
letter-spacing: 0.5px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.pd-sub {
|
||
margin: 0;
|
||
font-size: 0.8rem;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
/* ── Payday party: nyan-style colour-cycling celebration ── */
|
||
.payday-party {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.8rem;
|
||
margin-bottom: 1rem;
|
||
padding: 1rem 1.2rem;
|
||
border-radius: 14px;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||
background: linear-gradient(
|
||
90deg,
|
||
#ef4444,
|
||
#f97316,
|
||
#eab308,
|
||
#22c55e,
|
||
#3b82f6,
|
||
#a855f7,
|
||
#ec4899,
|
||
#ef4444
|
||
);
|
||
background-size: 300% 100%;
|
||
animation:
|
||
nyan-slide 3s linear infinite,
|
||
party-pop 0.45s ease-out;
|
||
box-shadow: 0 8px 24px rgba(168, 85, 247, 0.45);
|
||
}
|
||
@keyframes nyan-slide {
|
||
to {
|
||
background-position: 300% 0;
|
||
}
|
||
}
|
||
@keyframes party-pop {
|
||
from {
|
||
transform: scale(0.85);
|
||
}
|
||
to {
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
.party-cat {
|
||
font-size: 2.6rem;
|
||
animation: party-bounce 0.55s ease-in-out infinite alternate;
|
||
}
|
||
@keyframes party-bounce {
|
||
from {
|
||
transform: translateY(-4px) rotate(-10deg);
|
||
}
|
||
to {
|
||
transform: translateY(4px) rotate(10deg);
|
||
}
|
||
}
|
||
.party-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
}
|
||
.party-title {
|
||
margin: 0;
|
||
font-size: 1.5rem;
|
||
font-weight: 900;
|
||
letter-spacing: 0.1em;
|
||
animation: party-wiggle 1s ease-in-out infinite;
|
||
}
|
||
@keyframes party-wiggle {
|
||
0%,
|
||
100% {
|
||
transform: rotate(-2deg);
|
||
}
|
||
50% {
|
||
transform: rotate(2deg);
|
||
}
|
||
}
|
||
.party-sub {
|
||
margin: 0;
|
||
font-size: 0.85rem;
|
||
opacity: 0.95;
|
||
}
|
||
.party-stars {
|
||
margin-left: auto;
|
||
font-size: 1.4rem;
|
||
animation: party-bounce 0.55s ease-in-out infinite alternate-reverse;
|
||
}
|
||
/* Mobile: declared last so it wins over the base .goal-mini (.kanban)
|
||
rules (same specificity, source order decides). */
|
||
@media (max-width: 639px) {
|
||
.goal-mini-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.kanban {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
/* Admin overview cards flow full width on phones — same convention as
|
||
the chores kanban (no horizontal scroll). */
|
||
.kanban-scroll {
|
||
overflow-x: visible;
|
||
}
|
||
.kanban-inner {
|
||
min-width: 0;
|
||
}
|
||
/* Keep the orange wash visible: restore body padding for this card
|
||
only (the shared Card removes it on mobile). */
|
||
:global(.card.claims-card .card-body) {
|
||
padding: 0.75rem;
|
||
}
|
||
/* Claim rows: icon/label/value stay on one line (label truncates
|
||
with ellipsis) and the Issue button becomes a full-width action
|
||
below — no squishing or overflow under 450px. */
|
||
.member-claims form.flex {
|
||
flex-wrap: wrap;
|
||
row-gap: 0.5rem;
|
||
}
|
||
.member-claims form.flex > div {
|
||
flex: 1 1 100%;
|
||
min-width: 0;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
}
|
||
/* :global — the button element is rendered by the shared Button
|
||
component, so a scoped selector never matches it. */
|
||
.member-claims form.flex :global(button) {
|
||
flex: 1 1 100%;
|
||
width: 100%;
|
||
margin-left: 0;
|
||
}
|
||
.member-claims .claim-label {
|
||
white-space: nowrap;
|
||
}
|
||
.member-claims .claim-item {
|
||
flex-wrap: wrap;
|
||
}
|
||
}
|
||
/* Claims card — orange wash so pending requests are impossible to miss.
|
||
:global + !important deliberately override the shared Card's white/
|
||
mobile-transparent backgrounds. */
|
||
:global(.card.claims-card) {
|
||
background: linear-gradient(135deg, #ffedd5, #fdba74 70%, #fb923c) !important;
|
||
border-color: #f97316;
|
||
}
|
||
</style>
|