2293 lines
57 KiB
Svelte
2293 lines
57 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, formatShortDate } from '$lib/format';
|
||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||
import {
|
||
weekStart as tzWeekStart,
|
||
addDaysStr,
|
||
todayInTz,
|
||
weekdayInTz,
|
||
wallClockToUtc,
|
||
resolveTz,
|
||
periodStart,
|
||
periodEnd
|
||
} from '../../../../../timezone.ts';
|
||
|
||
interface Notification {
|
||
id: string;
|
||
famId: string;
|
||
memberId: string;
|
||
message: string;
|
||
read: boolean;
|
||
created: string;
|
||
}
|
||
|
||
let { data } = $props();
|
||
|
||
let role = $state(data.role || 'child');
|
||
let famSlug = $derived(page.params.fam);
|
||
let username = $derived(page.params.username);
|
||
|
||
// ─── 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 simulateEow = $state(!!(data.settings?.simulateEow ?? data.simulateEow));
|
||
let eowPreview = $state<any>(null);
|
||
const responseTags = [
|
||
'👏 well done',
|
||
'😊 really pleased',
|
||
'🎯 you deserved that',
|
||
'💪 great effort',
|
||
'🙏 thanks'
|
||
];
|
||
let selectedMessage = $state<Record<string, string>>({});
|
||
let customMessage = $state<Record<string, string>>({});
|
||
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 totalChoresFor(memberId: string): number {
|
||
return parentAssigned.filter((a: any) => a.memberId === memberId).length;
|
||
}
|
||
|
||
function todayCompletionsFor(memberId: string) {
|
||
return parentCompletions.filter(
|
||
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
|
||
);
|
||
}
|
||
|
||
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')
|
||
);
|
||
}
|
||
|
||
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 }, onSuccess = null) {
|
||
const d = result.data || {};
|
||
if (d.error) showToast(d.error);
|
||
else if (result.type === 'success') {
|
||
if (onSuccess) onSuccess(d);
|
||
fire();
|
||
}
|
||
}
|
||
|
||
function rewardLabel(r: any): string {
|
||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||
if (r.rewardType === 'points') return `${r.value} pts`;
|
||
return r.label;
|
||
}
|
||
|
||
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;
|
||
});
|
||
}
|
||
|
||
// ─── Child View (kanban) ───
|
||
let memberId = $state(data.memberId || '');
|
||
let deviceToken = $state(data.token || '');
|
||
let famId = $state(data.famId || '');
|
||
let memberName = $state(data.memberName || '');
|
||
let memberColor = $state(data.memberColor || '#6366f1');
|
||
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[])
|
||
);
|
||
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<string>('');
|
||
|
||
// ─── Payday countdown (child view) ───
|
||
let nowMs = $state(Date.now());
|
||
let eowFired = $state(false);
|
||
$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 (!deviceToken || !famId) return;
|
||
eowFired = true;
|
||
memberApi.payday(deviceToken, famId).catch(() => {});
|
||
});
|
||
|
||
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);
|
||
const daysLeft = $derived(
|
||
Math.max(
|
||
0,
|
||
Math.round(
|
||
(new Date(addDays(weekStart, 7) + '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 !== 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 === 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 || '');
|
||
})
|
||
);
|
||
|
||
let dailyPending = $derived(
|
||
memberChores.filter((a) => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
|
||
);
|
||
let weeklyPending = $derived(
|
||
memberChores.filter((a) => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
|
||
);
|
||
|
||
// 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) === todayChild)
|
||
);
|
||
|
||
let weeklyTotal = $derived.by(() => {
|
||
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
|
||
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
|
||
const activeTodos = memberTodos.filter((t) => t.type !== 'emoji').length;
|
||
return dailyChores.length * 7 + weeklyChores.length + activeTodos;
|
||
});
|
||
|
||
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
|
||
)
|
||
);
|
||
let weeklyDone = $derived(weekCompletions.length);
|
||
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0);
|
||
|
||
let weekRewards = $derived(
|
||
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
|
||
);
|
||
|
||
let allTimeCash = $derived.by(() =>
|
||
rewards
|
||
.filter((r) => r.memberId === memberId && r.rewardType === 'cash' && r.status === 'claimed')
|
||
.reduce((sum, r) => sum + Number(r.value), 0)
|
||
);
|
||
|
||
let allTimePoints = $derived.by(() =>
|
||
rewards
|
||
.filter((r) => r.memberId === memberId && r.rewardType === 'points' && r.status === 'claimed')
|
||
.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)
|
||
);
|
||
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 (stamped server-side).
|
||
function paydayLocked(r: any) {
|
||
return r.claimable === 'payday' && r.settleDate && todayChild < r.settleDate;
|
||
}
|
||
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);
|
||
}
|
||
return [...map.entries()].map(([name, count]) => ({ name, count }));
|
||
});
|
||
|
||
// ─── Threshold Goals (bonus configs the member is chasing) ───
|
||
let thresholdGoals = $derived.by(() => {
|
||
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 per = cfg.period || 'weekly';
|
||
const pStart = periodStart(per, paydayDay, famTz);
|
||
const pEnd = periodEnd(per, pStart);
|
||
|
||
const periodCompletions = completions.filter(
|
||
(c) =>
|
||
c.memberId === memberId &&
|
||
(c.date?.slice(0, 10) || c.date) >= pStart &&
|
||
(c.date?.slice(0, 10) || c.date) <= pEnd
|
||
);
|
||
|
||
let current = 0;
|
||
if (cfg.type === 'threshold') {
|
||
current = periodCompletions.reduce((sum, c) => {
|
||
const chore = assigned.find((a) => a.id === c.assignedChoreId);
|
||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
|
||
}, 0);
|
||
} else if (cfg.type === 'count') {
|
||
current = periodCompletions.length;
|
||
}
|
||
|
||
const existingReward = rewards.find(
|
||
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
|
||
);
|
||
const criteria = Number(cfg.criteriaValue) || 0;
|
||
const achieved = existingReward ? true : criteria > 0 && current >= criteria;
|
||
|
||
return {
|
||
config: cfg,
|
||
current,
|
||
criteriaValue: criteria,
|
||
achieved,
|
||
rewardStatus: existingReward?.status || null,
|
||
periodStart: pStart,
|
||
periodEnd: pEnd
|
||
};
|
||
});
|
||
});
|
||
|
||
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((isCurrentWeek ? todayIso : weekEnd) + 'T00:00:00');
|
||
}
|
||
function headDay(): number {
|
||
return headDate().getDate();
|
||
}
|
||
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 (!deviceToken) {
|
||
deviceToken = localStorage.getItem('deviceToken') || '';
|
||
} else {
|
||
localStorage.setItem('deviceToken', deviceToken);
|
||
}
|
||
|
||
if (!deviceToken) {
|
||
error = 'No device token found. Use the link from your invite.';
|
||
loading = false;
|
||
return;
|
||
}
|
||
|
||
if (data.verified && data.famId && data.memberId) {
|
||
memberId = data.memberId;
|
||
famId = data.famId;
|
||
loading = false;
|
||
} else {
|
||
error = 'Invalid device token. Use the link from your invite.';
|
||
loading = false;
|
||
return;
|
||
}
|
||
|
||
await loadAndDismiss();
|
||
});
|
||
|
||
async function loadAndDismiss() {
|
||
if (!deviceToken || !famId) return;
|
||
try {
|
||
const res = await fetch('/api/members/notifications', {
|
||
headers: {
|
||
'x-device-token': deviceToken,
|
||
'x-device-famid': famId
|
||
}
|
||
});
|
||
if (!res.ok) return;
|
||
const all: Notification[] = await res.json();
|
||
for (const n of all.filter((n) => !n.read)) {
|
||
await fetch(`/api/members/notifications/${n.id}/dismiss`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'x-device-token': deviceToken,
|
||
'x-device-famid': famId
|
||
}
|
||
});
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function isCompleted(assignedChoreId: string, date: string): boolean {
|
||
return completions.some(
|
||
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
|
||
);
|
||
}
|
||
|
||
function findCompletion(assignedChoreId: string) {
|
||
const match = (c: Completion) =>
|
||
c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild;
|
||
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
||
return optimistic || completions.find(match);
|
||
}
|
||
|
||
async function toggle(chore: AssignedChore) {
|
||
if (togglingIds) return;
|
||
togglingIds = chore.id;
|
||
|
||
const wasCompleted = isCompleted(chore.id, todayChild);
|
||
if (wasCompleted) {
|
||
const existing = findCompletion(chore.id);
|
||
if (existing) {
|
||
famStore.applyRecord('completions', existing, 'delete');
|
||
}
|
||
} else {
|
||
famStore.applyRecord(
|
||
'completions',
|
||
{
|
||
id: 'optimistic-' + chore.id,
|
||
famId,
|
||
memberId,
|
||
assignedChoreId: chore.id,
|
||
date: todayChild,
|
||
completedAt: new Date().toISOString()
|
||
} as Completion,
|
||
'create'
|
||
);
|
||
}
|
||
|
||
try {
|
||
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild);
|
||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||
if (optimistic) {
|
||
famStore.applyRecord('completions', optimistic, 'delete');
|
||
}
|
||
} catch (e) {
|
||
if (wasCompleted) {
|
||
famStore.applyRecord(
|
||
'completions',
|
||
{
|
||
id: 'revert-' + chore.id + '-' + Date.now(),
|
||
famId,
|
||
memberId,
|
||
assignedChoreId: chore.id,
|
||
date: todayChild,
|
||
completedAt: new Date().toISOString()
|
||
} as Completion,
|
||
'create'
|
||
);
|
||
} else {
|
||
const optimistic = completions.find((c) => c.id === 'optimistic-' + chore.id);
|
||
if (optimistic) {
|
||
famStore.applyRecord('completions', optimistic, 'delete');
|
||
}
|
||
}
|
||
console.error('Toggle failed:', e);
|
||
} finally {
|
||
togglingIds = '';
|
||
}
|
||
}
|
||
|
||
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>
|
||
|
||
<div class="kanban-scroll">
|
||
<div class="kanban-inner">
|
||
<CardGrid>
|
||
<Card cols={1} title="Members">
|
||
{#each parentMembers as m}
|
||
{@const total = totalChoresFor(m.id)}
|
||
{@const todays = todayCompletionsFor(m.id)}
|
||
{@const done = todays.length}
|
||
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
|
||
{@const s = memberInSummary(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>
|
||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
||
</div>
|
||
<div class="stats">
|
||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
|
||
</div>
|
||
<div class="progress-row">
|
||
<span class="label">Today:</span>
|
||
<div class="bar-wrap">
|
||
<div class="bar-fill" style="width:{pct}%"></div>
|
||
</div>
|
||
<span class="count">{done}/{total}</span>
|
||
</div>
|
||
{#if done > 0}
|
||
<ul class="done-list">
|
||
{#each todays as c}
|
||
<li>
|
||
✅ {choreNameFor(c.assignedChoreId)}
|
||
<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">↩</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="#f59e0b">
|
||
{#if claimableRewards().length === 0}
|
||
<p class="empty">No outstanding claims</p>
|
||
{:else}
|
||
{#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">
|
||
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
||
{#if total > 0}
|
||
<p class="member-total">£{total.toFixed(2)} owed</p>
|
||
{/if}
|
||
|
||
<!-- Issue All: only when payday has run (rewards are requested) -->
|
||
{#if cashRequested.length > 0}
|
||
<form
|
||
class="issue-all-form"
|
||
method="POST"
|
||
action="?/issueAll"
|
||
use:enhance={() => {
|
||
return async (args: any) => handleResult(args);
|
||
}}
|
||
>
|
||
<input type="hidden" name="memberId" value={m.id} />
|
||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||
<Button type="submit" size="sm" variant="secondary"
|
||
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
|
||
>
|
||
</form>
|
||
{/if}
|
||
|
||
{#if requested.length > 0}
|
||
<p class="section-label">Requested</p>
|
||
{#each requested as r}
|
||
<form
|
||
method="POST"
|
||
action="?/claim"
|
||
use:enhance={() => {
|
||
return async (args: any) => handleResult(args);
|
||
}}
|
||
>
|
||
<input type="hidden" name="id" value={r.id} />
|
||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||
<div class="payment-row">
|
||
<span>{r.label}</span>
|
||
<div class="payment-right">
|
||
<span class="value">{rewardLabel(r)}</span>
|
||
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
{/each}
|
||
{/if}
|
||
|
||
{#if outstanding.length > 0}
|
||
<p class="section-label">Outstanding</p>
|
||
{#each outstanding as r}
|
||
<div class="payment-row outstanding">
|
||
<span>{r.label}</span>
|
||
<span class="value">
|
||
{rewardLabel(r)}
|
||
{#if paydayLocked(r)}
|
||
<span class="owe-locked">🔒 {formatShortDate(r.settleDate)}</span>
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
|
||
<div class="response-area">
|
||
<p class="respond-label">Response:</p>
|
||
<div class="tags">
|
||
{#each responseTags as tag}
|
||
<button
|
||
type="button"
|
||
class="tag"
|
||
class:selected={selectedMessage[m.id] === tag}
|
||
onclick={() =>
|
||
(selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag)}
|
||
>{tag}</button
|
||
>
|
||
{/each}
|
||
</div>
|
||
<input
|
||
type="text"
|
||
class="custom-msg"
|
||
placeholder="Or type your own..."
|
||
bind:value={customMessage[m.id]}
|
||
oninput={() => {
|
||
if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id];
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
{/if}
|
||
</Card>
|
||
</CardGrid>
|
||
</div>
|
||
</div>
|
||
<CardGrid>
|
||
<Card cols={3} title="Debug: Preview payday" accent="#f59e0b">
|
||
<p class="eow-desc">
|
||
Read-only preview of what the payday rollover will settle for this week. Nothing here is
|
||
written.
|
||
</p>
|
||
|
||
<form
|
||
method="POST"
|
||
action="?/previewEow"
|
||
use:enhance={() => {
|
||
return async ({ result }) => {
|
||
const d = (result as any).data || {};
|
||
if (d.error) toast = d.error;
|
||
else {
|
||
eowPreview = d.preview;
|
||
simulateEow = !!d.simulateEow;
|
||
}
|
||
};
|
||
}}
|
||
>
|
||
<Button type="submit" size="sm" variant="secondary">Preview payday</Button>
|
||
</form>
|
||
|
||
{#if simulateEow}
|
||
<p class="eow-mode-on">
|
||
👀 Preview mode is ON — child dashboards show a "payday preview" notice.
|
||
</p>
|
||
<form
|
||
method="POST"
|
||
action="?/setEow"
|
||
use:enhance={() => {
|
||
return async ({ result }) => {
|
||
const d = (result as any).data || {};
|
||
if (d.error) toast = d.error;
|
||
else simulateEow = !!d.simulateEow;
|
||
};
|
||
}}
|
||
>
|
||
<input type="hidden" name="on" value="false" />
|
||
<Button type="submit" size="sm" variant="ghost">Turn off preview</Button>
|
||
</form>
|
||
{/if}
|
||
|
||
{#if eowPreview}
|
||
<div class="eow-out">
|
||
<p class="eow-range">
|
||
Week {formatDDMMYY(eowPreview.weekStart)} → {formatDDMMYY(eowPreview.weekEnd)}
|
||
</p>
|
||
<p class="eow-reset">
|
||
On payday these {eowPreview.completionsThisWeek} completions reset; week starts anew at
|
||
{formatDDMMYY(eowPreview.nextWeekStart)}.
|
||
</p>
|
||
|
||
<div class="eow-summaries">
|
||
{#each eowPreview.summaries as s}
|
||
<div class="eow-row">
|
||
<span class="eow-name">{s.memberName}</span>
|
||
<span class="eow-pts">{s.pointsEarned} pts</span>
|
||
<span class="eow-money">£{Number(s.moneyEarned || 0).toFixed(2)}</span>
|
||
<span class="eow-chores">{s.choresCompleted} chores</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if eowPreview.predictedRewards.length}
|
||
<p class="eow-sec">Rewards that would be auto-created</p>
|
||
<div class="eow-rewards">
|
||
{#each eowPreview.predictedRewards as r}
|
||
<span class="eow-reward"
|
||
>{r.memberName} · {r.config} · {r.type === 'cash'
|
||
? '£' + Number(r.value).toFixed(2)
|
||
: r.value + ' pts'}</span
|
||
>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<p class="eow-none">No new rewards would be auto-created this week.</p>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</Card>
|
||
</CardGrid>
|
||
{:else}
|
||
<!-- Child View -->
|
||
{#if loading}
|
||
<p class="empty">Loading...</p>
|
||
{:else if error}
|
||
<p class="error">{error}</p>
|
||
{:else}
|
||
{#if simulateEow}
|
||
<div class="preview-notice">
|
||
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
|
||
out yet.
|
||
</div>
|
||
{/if}
|
||
{#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">💰</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}
|
||
<!-- HERO -->
|
||
<header class="hero">
|
||
<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/me', {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-device-token': deviceToken,
|
||
'x-device-famid': famId
|
||
},
|
||
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-day">{headDay()}</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/me', {
|
||
method: 'PATCH',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-device-token': deviceToken,
|
||
'x-device-famid': famId
|
||
},
|
||
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="bar-wrap hero-bar">
|
||
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div>
|
||
</div>
|
||
<span class="hero-count">
|
||
{weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="hero-meta">
|
||
{#if weekBonusTallies.length > 0}
|
||
<div class="hero-pills">
|
||
{#each weekBonusTallies as t}
|
||
<span class="hero-pill">🎉 {t.name} ×{t.count}</span>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<span class="hero-null">no bonuses earned yet</span>
|
||
{/if}
|
||
<div class="hero-days"><b>{daysLeft}</b> days until payday</div>
|
||
</div>
|
||
|
||
<nav class="hero-nav">
|
||
<button class="hero-nav-btn" onclick={() => navigate(-1)} aria-label="Previous week"
|
||
>◀</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">▶</button>
|
||
</nav>
|
||
</header>
|
||
|
||
<!-- WEEK-TO-DATE TILES -->
|
||
<div class="tiles">
|
||
<div class="tile tile-cash">
|
||
<span class="tile-val">£{weekChoreCash.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.criteriaValue > 0
|
||
? Math.min(100, Math.round((goal.current / goal.criteriaValue) * 100))
|
||
: 0}
|
||
<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'}
|
||
🎁
|
||
{:else}
|
||
{goal.config.rewardValue} pts
|
||
{/if}
|
||
</span>
|
||
</div>
|
||
<div class="goal-bar-wrap">
|
||
<div class="goal-bar-fill" style="width:{pct}%"></div>
|
||
</div>
|
||
<div class="goal-foot">
|
||
<span class="goal-progress"
|
||
>{Math.min(goal.current, goal.criteriaValue)} / {goal.criteriaValue}</span
|
||
>
|
||
{#if goal.achieved}
|
||
<span class="goal-badge">✅ earned</span>
|
||
{:else}
|
||
<span class="goal-pct">{pct}%</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- KANBAN -->
|
||
<div class="kanban">
|
||
<div class="column col-daily">
|
||
<h2>🎯 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}>
|
||
<span class="checkbox">⬜</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>
|
||
📅 Weekly ({weeklyPending.length +
|
||
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
|
||
</h2>
|
||
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
|
||
<p class="empty">All done!</p>
|
||
{:else}
|
||
{#each weeklyPending as chore}
|
||
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
|
||
<span class="checkbox">⬜</span>
|
||
<span class="chore-name">{choreName(chore)}</span>
|
||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||
</button>
|
||
{/each}
|
||
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
|
||
{@const urgency = todoUrgency(todo)}
|
||
{#if todo.type === 'emoji'}
|
||
<div
|
||
class="chore todo-chore todo-display"
|
||
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'}
|
||
>
|
||
<span class="checkbox">📋</span>
|
||
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||
<span class="todo-label">todo</span>
|
||
<span class="chore-value">🎯</span>
|
||
</div>
|
||
{: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'}
|
||
onclick={() => toggle(todo)}
|
||
disabled={!!togglingIds}
|
||
>
|
||
<span class="checkbox">📋</span>
|
||
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||
<span class="todo-label">todo</span>
|
||
<span class="chore-value">{todo.value} {todo.type}</span>
|
||
</button>
|
||
{/if}
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
<div class="column col-done">
|
||
<h2>✅ 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}
|
||
<button class="chore done" onclick={() => toggle(chore)} disabled={!!togglingIds}>
|
||
<span class="checkbox">✅</span>
|
||
<span class="chore-name">{choreName(chore)}</span>
|
||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||
</button>
|
||
{/if}
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WALLET / CLAIMS -->
|
||
<div class="wallet">
|
||
<div class="wallet-top">
|
||
<span class="wallet-lbl">💰 Wallet</span>
|
||
{#if allTimeCash > 0 || allTimePoints > 0}
|
||
<span class="wallet-total">
|
||
<span class="wallet-cash">£{allTimeCash.toFixed(2)}</span>
|
||
<span class="wallet-dot">·</span>
|
||
<span class="wallet-pts">{allTimePoints} pts</span>
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
{#if pendingRewards.length === 0}
|
||
<p class="wallet-empty">No rewards waiting</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'}
|
||
🎁
|
||
{:else}
|
||
{r.value} pts
|
||
{/if}
|
||
</span>
|
||
{#if r.rewardType === 'points'}
|
||
<span class="wr-pending">✓ credited</span>
|
||
{:else if isReq}
|
||
<span class="wr-pending">⏳ waiting</span>
|
||
{:else if paydayLocked(r)}
|
||
<span class="wr-pending wr-locked"
|
||
>🔒 pays out {formatShortDate(r.settleDate)}</span
|
||
>
|
||
{:else}
|
||
<button
|
||
class="wr-cta"
|
||
onclick={async () => {
|
||
try {
|
||
await memberApi.claimReward(deviceToken, 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: white;
|
||
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;
|
||
}
|
||
.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;
|
||
}
|
||
.done-list li {
|
||
font-size: 0.75rem;
|
||
padding: 0.05rem 0;
|
||
color: #059669;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
.revoke-form {
|
||
display: inline;
|
||
}
|
||
.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: 0.75rem;
|
||
}
|
||
.member-claims:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
.member-claims h4 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
margin: 0 0 0.2rem;
|
||
font-size: 0.85rem;
|
||
}
|
||
.payment-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.8rem;
|
||
padding: 0.1rem 0;
|
||
}
|
||
.payment-row .value {
|
||
font-weight: 600;
|
||
color: #059669;
|
||
}
|
||
.payment-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.respond-label {
|
||
font-size: 0.8rem;
|
||
color: #6b7280;
|
||
margin: 0.4rem 0 0.3rem;
|
||
}
|
||
.tags {
|
||
display: flex;
|
||
gap: 0.3rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.tag {
|
||
font-size: 0.75rem;
|
||
padding: 0.2rem 0.5rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 999px;
|
||
background: white;
|
||
cursor: pointer;
|
||
}
|
||
.tag:hover {
|
||
background: #f3f4f6;
|
||
}
|
||
.tag.selected {
|
||
background: #6366f1;
|
||
color: white;
|
||
border-color: #6366f1;
|
||
}
|
||
.custom-msg {
|
||
margin-top: 0.3rem;
|
||
padding: 0.3rem 0.5rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.error {
|
||
color: #dc2626;
|
||
padding: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.section-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
margin: 0.5rem 0 0.25rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.member-total {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: #2563eb;
|
||
margin: 0 0 0.25rem;
|
||
}
|
||
.payment-row.outstanding {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
.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-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: 12px;
|
||
background: rgba(255, 255, 255, 0.28);
|
||
}
|
||
.hero-fill {
|
||
background: #fff;
|
||
}
|
||
.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;
|
||
}
|
||
.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-null {
|
||
font-size: 0.75rem;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
}
|
||
.hero-days {
|
||
font-size: 0.85rem;
|
||
color: rgba(255, 255, 255, 0.9);
|
||
}
|
||
.hero-days b {
|
||
font-size: 1.4rem;
|
||
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: #fff;
|
||
color: #6d28d9;
|
||
}
|
||
|
||
/* ── 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: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.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: 0.85rem;
|
||
font-weight: 800;
|
||
color: #6366f1;
|
||
white-space: nowrap;
|
||
}
|
||
.goal-achieved .goal-reward {
|
||
color: #059669;
|
||
}
|
||
.goal-bar-wrap {
|
||
height: 8px;
|
||
background: #f3f4f6;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
margin-bottom: 0.35rem;
|
||
}
|
||
.goal-bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
.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;
|
||
}
|
||
|
||
/* ── Kanban ── */
|
||
.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;
|
||
}
|
||
.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-display {
|
||
cursor: default;
|
||
opacity: 0.85;
|
||
}
|
||
.todo-display:hover {
|
||
transform: none;
|
||
box-shadow: none;
|
||
border-top-color: #e5e7eb;
|
||
border-right-color: #e5e7eb;
|
||
border-bottom-color: #e5e7eb;
|
||
}
|
||
.chore:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
.done {
|
||
opacity: 0.7;
|
||
}
|
||
.checkbox {
|
||
font-size: 1rem;
|
||
flex-shrink: 0;
|
||
}
|
||
.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-total {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
align-items: center;
|
||
font-size: 0.9rem;
|
||
font-weight: 800;
|
||
}
|
||
.wallet-cash {
|
||
color: #059669;
|
||
}
|
||
.wallet-pts {
|
||
color: #7c3aed;
|
||
}
|
||
.wallet-dot {
|
||
color: #d1d5db;
|
||
}
|
||
.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-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-desc {
|
||
margin: 0 0 0.6rem;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.preview-notice {
|
||
margin-bottom: 1rem;
|
||
padding: 0.85rem 1.1rem;
|
||
border-radius: 12px;
|
||
border: 1px dashed #f59e0b;
|
||
background: #fffbeb;
|
||
color: #92400e;
|
||
font-size: 0.95rem;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.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, #4f46e5, #7c3aed);
|
||
color: #fff;
|
||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.35);
|
||
}
|
||
.pd-icon {
|
||
font-size: 1.8rem;
|
||
}
|
||
.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;
|
||
}
|
||
.eow-out {
|
||
margin-top: 0.75rem;
|
||
border-top: 1px dashed #e5e7eb;
|
||
padding-top: 0.6rem;
|
||
}
|
||
.eow-range,
|
||
.eow-reset {
|
||
margin: 0.2rem 0;
|
||
font-size: 0.85rem;
|
||
}
|
||
.eow-reset {
|
||
color: #6b7280;
|
||
font-size: 0.8rem;
|
||
}
|
||
.eow-summaries {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
margin: 0.6rem 0;
|
||
}
|
||
.eow-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
padding: 0.35rem 0.5rem;
|
||
border: 1px solid #eeeff2;
|
||
border-radius: 8px;
|
||
background: #fafbfc;
|
||
font-size: 0.85rem;
|
||
}
|
||
.eow-name {
|
||
flex: 1;
|
||
font-weight: 600;
|
||
}
|
||
.eow-pts {
|
||
color: #7c3aed;
|
||
font-weight: 700;
|
||
}
|
||
.eow-money {
|
||
color: #059669;
|
||
font-weight: 700;
|
||
}
|
||
.eow-chores {
|
||
color: #6b7280;
|
||
}
|
||
.eow-sec {
|
||
font-size: 0.85rem;
|
||
font-weight: 700;
|
||
margin: 0.6rem 0 0.3rem;
|
||
}
|
||
.eow-rewards {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
}
|
||
.eow-reward {
|
||
font-size: 0.8rem;
|
||
padding: 0.3rem 0.55rem;
|
||
background: #eef2ff;
|
||
color: #4338ca;
|
||
border-radius: 6px;
|
||
}
|
||
.eow-none {
|
||
font-size: 0.8rem;
|
||
color: #9ca3af;
|
||
}
|
||
</style>
|