add more ux and ui patches with extra login helper

This commit is contained in:
JCEEE
2026-08-27 14:13:14 +01:00
parent 84b3cd48f4
commit 9f335db7b2
16 changed files with 643 additions and 150 deletions
+10 -6
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
let { cols = 3, children }: { cols?: number; children?: any } = $props(); let { stackTablet = false, children }: { stackTablet?: boolean; children?: any } = $props();
</script> </script>
<div class="card-grid" style="--grid-cols: {cols}"> <div class="card-grid" class:stack-tablet={stackTablet}>
{@render children?.()} {@render children?.()}
</div> </div>
@@ -10,18 +10,22 @@
.card-grid { .card-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(var(--grid-cols, 3), 1fr); grid-template-columns: repeat(3, 1fr);
} }
/* Tablet: settle to 2 columns */ /* Tablet: two columns */
@media (min-width: 640px) and (max-width: 1023px) { @media (min-width: 640px) and (max-width: 1023px) {
.card-grid { .card-grid {
--grid-cols: 2; grid-template-columns: repeat(2, 1fr);
}
/* Opt-in: stack to a single column on tablet */
.card-grid.stack-tablet {
grid-template-columns: 1fr;
} }
} }
/* Mobile: single column */ /* Mobile: single column */
@media (max-width: 639px) { @media (max-width: 639px) {
.card-grid { .card-grid {
--grid-cols: 1; grid-template-columns: 1fr;
} }
} }
</style> </style>
+4 -4
View File
@@ -3,8 +3,8 @@
import { import {
dashboardIcon, dashboardIcon,
choresIcon, choresIcon,
rewardsIcon, archiveIcon,
bonusesIcon, gemIcon,
homeIcon, homeIcon,
settingsIcon, settingsIcon,
logoutIcon, logoutIcon,
@@ -30,8 +30,8 @@
{ href: `/${famSlug}`, label: famName, icon: homeIcon }, { href: `/${famSlug}`, label: famName, icon: homeIcon },
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon }, { href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon }, { href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon }, { href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: gemIcon },
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon } { href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: archiveIcon }
] ]
: showChildItems : showChildItems
? [ ? [
+7 -1
View File
@@ -1,7 +1,11 @@
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>' export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>' export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
export const bonusesIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>' export const moneyIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>'
export const handCoinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>'
export const gemIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>'
export const archiveIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>'
export const todoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>'
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>' export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>' export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
@@ -11,3 +15,5 @@ export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fil
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>' export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>' export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
export const checkCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>'
export const revokeIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
+14 -9
View File
@@ -68,8 +68,12 @@ async function createRecord(collection: string, data: any): Promise<void> {
} }
} }
// Seed two default seasons (Holidays, Term time) per family so the family-admin // Seed a default season (Holidays) per family so the family-admin season
// season picker always has options. Idempotent by name. // picker always has at least one option. "Term time" is intentionally NOT
// auto-seeded — it's a UK-school-specific term that not every family wants,
// and force-seeding it meant a deleted "Term time" kept coming back on every
// boot. Families that want it can add it themselves.
// Only seeds when a family has zero seasons, so user deletions stick.
async function ensureDefaultSeasons(): Promise<void> { async function ensureDefaultSeasons(): Promise<void> {
const famsCol = await getCollection("fams"); const famsCol = await getCollection("fams");
if (!famsCol) return; if (!famsCol) return;
@@ -78,18 +82,19 @@ async function ensureDefaultSeasons(): Promise<void> {
headers: { Authorization: `Bearer ${t}` }, headers: { Authorization: `Bearer ${t}` },
}); });
const fams = (await famsRes.json()).items || []; const fams = (await famsRes.json()).items || [];
const defaults = [ const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }];
{ name: "Holidays", color: "#f59e0b", active: true },
{ name: "Term time", color: "#3b82f6", active: true },
];
for (const fam of fams) { for (const fam of fams) {
const sRes = await fetch( const sRes = await fetch(
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`, `${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
{ headers: { Authorization: `Bearer ${t}` } } { headers: { Authorization: `Bearer ${t}` } }
); );
const have = new Set(((await sRes.json()).items || []).map((s: any) => s.name)); const have = ((await sRes.json()).items || []) as any[];
for (const d of defaults) { // Only seed defaults for families that have no seasons at all. Seeding
if (!have.has(d.name)) { // per-name (idempotent) forced a deleted default (e.g. "Term time") back
// on every boot; now a user who removes one keeps it removed as long as
// at least one season remains.
if (have.length === 0) {
for (const d of defaults) {
await createRecord("seasons", { famId: fam.id, ...d }); await createRecord("seasons", { famId: fam.id, ...d });
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`); console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
} }
+26
View File
@@ -0,0 +1,26 @@
export type Shortcut = {
famSlug: string;
famName: string;
userName: string; // '' for parents (no handle in URL)
};
const KEY = 'fam_shortcut';
export function recordShortcut(entry: Shortcut) {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(KEY, JSON.stringify(entry));
}
export function readShortcut(): Shortcut | null {
if (typeof localStorage === 'undefined') return null;
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Shortcut) : null;
} catch {
return null;
}
}
export function shortcutTarget(s: Shortcut): string {
return s.userName ? `/${s.famSlug}/${s.userName}` : `/${s.famSlug}`;
}
+37 -2
View File
@@ -40,6 +40,7 @@ class ChatStore {
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
private lastSeenAt = 0; private lastSeenAt = 0;
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>(); private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
private audioCtx: AudioContext | null = null;
typingNames = $derived.by(() => { typingNames = $derived.by(() => {
const names: string[] = []; const names: string[] = [];
@@ -156,8 +157,9 @@ class ChatStore {
} }
// Stop showing their typing indicator once the message lands. // Stop showing their typing indicator once the message lands.
this.clearTyping(record.authorId); this.clearTyping(record.authorId);
if (record.authorId !== this.actorId && !this.open) { if (record.authorId !== this.actorId) {
this.unread++; if (!this.open) this.unread++;
this.playDunk();
} }
} }
@@ -184,6 +186,39 @@ class ChatStore {
if (this.typing[actorId]) this.removeTyping(actorId); if (this.typing[actorId]) this.removeTyping(actorId);
} }
// Short, low-volume "dunk" blip played when a message from someone else is
// broadcast in. Synthesised with the Web Audio API so there's no asset to
// ship. Browsers require a user gesture before audio can start, but chat
// messages only arrive after the user has interacted, so the context
// resumes fine. Failures are silently ignored.
private playDunk() {
if (typeof window === 'undefined' || typeof AudioContext === 'undefined') return;
try {
const Ctor = window.AudioContext || (window as any).webkitAudioContext;
if (!Ctor) return;
if (!this.audioCtx) this.audioCtx = new Ctor();
const ctx = this.audioCtx;
if (ctx.state === 'suspended') ctx.resume();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
// Quick downward pitch sweep gives the soft "dun-nk" thud.
osc.frequency.setValueAtTime(190, now);
osc.frequency.exponentialRampToValueAtTime(70, now + 0.18);
// Low volume with a fast attack/decay envelope.
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(0.12, now + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
osc.connect(gain).connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.24);
} catch {
/* audio is best-effort */
}
}
private removeTyping(actorId: string) { private removeTyping(actorId: string) {
if (!this.typing[actorId]) return; if (!this.typing[actorId]) return;
const next = { ...this.typing }; const next = { ...this.typing };
+74
View File
@@ -1,5 +1,15 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import { homeIcon } from '$lib/components/icons';
import { readShortcut, shortcutTarget, type Shortcut } from '$lib/shortcut';
let shortcut = $state<(Shortcut & { target: string }) | null>(null);
onMount(() => {
const s = readShortcut();
if (s) shortcut = { ...s, target: shortcutTarget(s) };
});
</script> </script>
<svelte:head> <svelte:head>
@@ -11,6 +21,18 @@
</svelte:head> </svelte:head>
<main class="landing"> <main class="landing">
{#if shortcut}
<a class="return-chip" href={shortcut.target} title="Jump back into {shortcut.famName}">
<span class="return-icon">
{@html homeIcon}
<span class="return-dot"></span>
</span>
<span class="return-text">
<span class="return-fam">{shortcut.famName}</span>
<span class="return-sub">Jump back in →</span>
</span>
</a>
{/if}
<!-- HERO --> <!-- HERO -->
<section class="hero"> <section class="hero">
<div class="hero-inner"> <div class="hero-inner">
@@ -110,6 +132,58 @@
color: #1f2937; color: #1f2937;
} }
/* RETURNING-USER SHORTCUT */
.return-chip {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 20;
display: flex;
align-items: center;
gap: 0.6rem;
width: fit-content;
padding: 0.45rem 0.9rem 0.45rem 0.55rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 999px;
text-decoration: none;
color: #1f2937;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.15s, box-shadow 0.15s;
}
.return-chip:hover {
border-color: #c7d2fe;
box-shadow: 0 2px 10px rgba(67, 56, 202, 0.14);
}
.return-icon {
position: relative;
display: inline-flex;
color: #4338ca;
}
.return-dot {
position: absolute;
right: -3px;
bottom: -3px;
width: 11px;
height: 11px;
background: #22c55e;
border: 2px solid #fff;
border-radius: 50%;
}
.return-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.return-fam {
font-weight: 700;
font-size: 0.92rem;
}
.return-sub {
font-size: 0.72rem;
color: #6b7280;
}
/* HERO */ /* HERO */
.hero { .hero {
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%); background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
+15 -1
View File
@@ -8,6 +8,7 @@
import { notices } from '$lib/stores/notices.svelte'; import { notices } from '$lib/stores/notices.svelte';
import { Sidebar, TopNav, Footer, Chat } from '$lib/components'; import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
import { chatIcon } from '$lib/components/icons'; import { chatIcon } from '$lib/components/icons';
import { recordShortcut } from '$lib/shortcut';
import type { Session } from '$lib/types'; import type { Session } from '$lib/types';
let { children, data } = $props(); let { children, data } = $props();
@@ -97,11 +98,24 @@
return map[reason] || 'Access paused.'; return map[reason] || 'Access paused.';
} }
// Claim toast watcher (admin only) // Claim toast watcher (admin only)
let claimToast = $state(''); let claimToast = $state('');
let prevClaimedIds = new Set<string>(); let prevClaimedIds = new Set<string>();
let mounted = false; let mounted = false;
// Persist a returning-user shortcut (localStorage) whenever an authenticated
// session exists. $effect (not onMount) so it also fires when the session
// appears after the layout was already mounted — e.g. a member redeeming
// their OTP on /{fam}/join/{username} then landing on /{fam}/{username}.
$effect(() => {
if (!page.data.session) return;
recordShortcut({
famSlug: page.params.fam || '',
famName: (page.data.fam?.name as string) || page.params.fam || '',
userName: page.data.role === 'parent' ? '' : page.params.username || ''
});
});
$effect(() => { $effect(() => {
if (!session || !famStore.initialized) return; if (!session || !famStore.initialized) return;
const rewards = famStore.rewards; const rewards = famStore.rewards;
+92 -30
View File
@@ -42,6 +42,27 @@
return assigned.filter((a: any) => a.memberId === memberId).length; return assigned.filter((a: any) => a.memberId === memberId).length;
} }
// Weekly completion potential for a member: each daily chore = 7 instances,
// each weekly/todo chore = 1. (The raw `s.totalChores` is a template count,
// which is why the old donut could read >100%.)
function weeklyPotentialFor(memberId: string) {
return assigned
.filter((a: any) => a.memberId === memberId)
.reduce((sum: number, a: any) => sum + (a.frequency === 'daily' ? 7 : 1), 0);
}
// Chores expected "today" = the member's daily chores (each due once today).
function todayPotentialFor(memberId: string) {
return assigned.filter((a: any) => a.memberId === memberId && a.frequency === 'daily').length;
}
// Today's completion count — tolerant of UTC vs fam-local date keys.
function todayDone(s: any): number {
if (!s.dayCompletions) return 0;
const utc = new Date().toISOString().slice(0, 10);
if (s.dayCompletions[utc] != null) return s.dayCompletions[utc];
const local = new Date().toLocaleDateString('en-CA');
return s.dayCompletions[local] || 0;
}
onMount(() => { onMount(() => {
if (!summary?.summaries?.length) return; if (!summary?.summaries?.length) return;
@@ -69,25 +90,42 @@
}); });
} }
for (const s of summary.summaries) { function makeDonut(canvas: HTMLCanvasElement, done: number, total: number, color: string) {
const canvas = donutCanvases[s.memberId];
if (!canvas) continue;
const done = s.choresCompleted || 0;
const total = s.totalChores || 0;
const remaining = Math.max(0, total - done); const remaining = Math.max(0, total - done);
donutCharts[s.memberId] = new Chart(canvas, { return new Chart(canvas, {
type: 'doughnut', type: 'doughnut',
data: { data: {
labels: ['Done', 'Remaining'], labels: ['Done', 'Remaining'],
datasets: [{ datasets: [{
data: [done, remaining], data: [done, remaining],
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'], backgroundColor: [color || '#22c55e', '#e5e7eb'],
borderWidth: 0, borderWidth: 0,
}], }],
}, },
options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } }, options: { responsive: true, cutout: '70%', plugins: { legend: { display: false } } },
}); });
} }
for (const s of summary.summaries) {
const todayCanvas = donutCanvases[`${s.memberId}:today`];
if (todayCanvas) {
donutCharts[`${s.memberId}:today`] = makeDonut(
todayCanvas,
todayDone(s),
todayPotentialFor(s.memberId),
s.memberColor
);
}
const weekCanvas = donutCanvases[`${s.memberId}:week`];
if (weekCanvas) {
donutCharts[`${s.memberId}:week`] = makeDonut(
weekCanvas,
s.choresCompleted || 0,
weeklyPotentialFor(s.memberId),
s.memberColor
);
}
}
}); });
onDestroy(() => { onDestroy(() => {
@@ -97,7 +135,7 @@
</script> </script>
<ViewHeader <ViewHeader
title={famSlug} title={page.data.fam?.name || famSlug}
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'} subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'}
hero hero
/> />
@@ -131,20 +169,30 @@
<div class="kanban"> <div class="kanban">
{#each summary.summaries as s} {#each summary.summaries as s}
{@const m = members.find((m2: any) => m2.id === s.memberId)} {@const m = members.find((m2: any) => m2.id === s.memberId)}
{@const total = totalChoresFor(s.memberId)} {@const wkTotal = weeklyPotentialFor(s.memberId)}
{@const pct = total > 0 ? Math.round((s.choresCompleted / total) * 100) : 0} {@const wkDone = s.choresCompleted || 0}
{@const wkPct = wkTotal > 0 ? Math.min(100, Math.round((wkDone / wkTotal) * 100)) : 0}
{@const tdTotal = todayPotentialFor(s.memberId)}
{@const tdDone = todayDone(s)}
{@const tdPct = tdTotal > 0 ? Math.min(100, Math.round((tdDone / tdTotal) * 100)) : 0}
<div class="column col-member"> <div class="column col-member">
<div class="member-hero"> <div class="member-head">
<div class="member-head"> <span class="dot" style="background:{s.memberColor}"></span>
<span class="dot" style="background:{s.memberColor}"></span> <span class="member-name">{s.memberName}</span>
<span class="member-name">{s.memberName}</span> {#if m}
{#if m} <a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a> {/if}
{/if} </div>
<div class="donuts">
<div class="donut-wrap">
<canvas use:captureCanvas={`${s.memberId}:today`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{tdPct}%</div>
<span class="donut-label">Today</span>
</div> </div>
<div class="donut-wrap"> <div class="donut-wrap">
<canvas use:captureCanvas={s.memberId} height="100" width="100"></canvas> <canvas use:captureCanvas={`${s.memberId}:week`} height="100" width="100"></canvas>
<div class="donut-center" style="color:{s.memberColor}">{pct}%</div> <div class="donut-center" style="color:{s.memberColor}">{wkPct}%</div>
<span class="donut-label">Week</span>
</div> </div>
</div> </div>
<div class="member-stats"> <div class="member-stats">
@@ -158,12 +206,7 @@
</div> </div>
<div class="stat-row"> <div class="stat-row">
<span class="stat-lbl">Done</span> <span class="stat-lbl">Done</span>
<span class="stat-val">{s.choresCompleted}/{total}</span> <span class="stat-val">{wkDone}/{wkTotal}</span>
</div>
</div>
<div class="progress-row">
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -250,8 +293,9 @@
.member-hero { .member-hero {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.75rem; align-items: stretch;
gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.member-head { .member-head {
@@ -283,13 +327,19 @@
} }
.kanban-link:hover { text-decoration: underline; } .kanban-link:hover { text-decoration: underline; }
.donuts {
display: flex;
justify-content: center;
gap: 0.85rem;
margin: 0.25rem 0 0.75rem;
}
.donut-wrap { .donut-wrap {
position: relative; position: relative;
width: 70px; height: 70px; width: 62px; height: 62px;
flex-shrink: 0; flex-shrink: 0;
} }
.donut-wrap canvas { .donut-wrap canvas {
max-width: 70px; max-height: 70px; max-width: 62px; max-height: 62px;
} }
.donut-center { .donut-center {
position: absolute; position: absolute;
@@ -297,10 +347,22 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 800; font-weight: 800;
pointer-events: none; pointer-events: none;
} }
.donut-label {
position: absolute;
bottom: -15px;
left: 0;
right: 0;
text-align: center;
font-size: 0.6rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.member-stats { .member-stats {
display: flex; display: flex;
+185 -56
View File
@@ -6,6 +6,7 @@
import { memberApi } from '$lib/client/api'; import { memberApi } from '$lib/client/api';
import { formatDDMMYY, formatHumanDate } from '$lib/format'; import { formatDDMMYY, formatHumanDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { checkCircleIcon, revokeIcon } from '$lib/components/icons';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types'; import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { import {
@@ -64,10 +65,6 @@
}; };
}); });
function totalChoresFor(memberId: string): number {
return parentAssigned.filter((a: any) => a.memberId === memberId).length;
}
function todayCompletionsFor(memberId: string) { function todayCompletionsFor(memberId: string) {
return parentCompletions.filter( return parentCompletions.filter(
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today (c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
@@ -332,10 +329,17 @@
); );
let weeklyTotal = $derived.by(() => { let weeklyTotal = $derived.by(() => {
const dailyChores = memberChores.filter((a) => a.frequency === 'daily'); // Must match the pocket-money % threshold definition: all assigned
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly'); // non-todo chores for the member (no season filter), weighted by the
const activeTodos = memberTodos.filter((t) => t.type !== 'emoji').length; // number of days in the displayed period — so the hero total agrees
return dailyChores.length * 7 + weeklyChores.length + activeTodos; // with the Goals progress instead of showing a different denominator.
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( let weekCompletions = $derived(
@@ -349,6 +353,17 @@
let weeklyDone = $derived(weekCompletions.length); let weeklyDone = $derived(weekCompletions.length);
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0); 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( let weekRewards = $derived(
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart) rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
); );
@@ -505,10 +520,15 @@
? totalPotential > 0 && ratio * 100 >= configured ? totalPotential > 0 && ratio * 100 >= configured
: target > 0 && current >= target; : target > 0 && current >= target;
return { const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts';
config: cfg, const targetLabel = `${configured} ${unit}`;
current, const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100;
criteriaValue: configured, return {
config: cfg,
current,
criteriaValue: configured,
targetPct,
targetLabel,
target, target,
totalPotential, totalPotential,
isPercent, isPercent,
@@ -705,13 +725,11 @@
<div class="kanban-scroll"> <div class="kanban-scroll">
<div class="kanban-inner"> <div class="kanban-inner">
<CardGrid> <CardGrid stackTablet>
<Card cols={1} title="Members"> <Card cols={1} title="Members">
{#each parentMembers as m} {#each parentMembers as m}
{@const total = totalChoresFor(m.id)}
{@const todays = todayCompletionsFor(m.id)} {@const todays = todayCompletionsFor(m.id)}
{@const done = todays.length} {@const done = todays.length}
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
{@const s = memberInSummary(m.id)} {@const s = memberInSummary(m.id)}
{@const pm = parentBonusConfigs.find( {@const pm = parentBonusConfigs.find(
(c: any) => c.isPocketMoney && c.memberId === m.id (c: any) => c.isPocketMoney && c.memberId === m.id
@@ -722,7 +740,7 @@
<span class="member-name">{m.name}</span> <span class="member-name">{m.name}</span>
{#if !(pm && pm.rewardValue)} {#if !(pm && pm.rewardValue)}
<Button <Button
href="/{famSlug}/{username}/bonuses?s=pocketmoney" href="/{famSlug}/{username}/rewards?s=pocketmoney"
variant="secondary" variant="secondary"
size="sm">Pocket money</Button size="sm">Pocket money</Button
> >
@@ -742,18 +760,12 @@
<span class="unset">Pocket money: amount not set</span> <span class="unset">Pocket money: amount not set</span>
{/if} {/if}
</div> </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} {#if done > 0}
<ul class="done-list"> <ul class="done-list">
{#each todays as c} {#each todays as c, i}
<li> <li class="done-item" class:odd={i % 2 === 1}>
✅ {choreNameFor(c.assignedChoreId)} <span class="done-icon">{@html checkCircleIcon}</span>
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
<form <form
method="POST" method="POST"
action="?/revoke" action="?/revoke"
@@ -763,7 +775,7 @@
class="revoke-form" class="revoke-form"
> >
<input type="hidden" name="id" value={c.id} /> <input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke">↩</button> <button type="submit" class="revoke-btn" title="Revoke">{@html revokeIcon}</button>
</form> </form>
</li> </li>
{/each} {/each}
@@ -1145,8 +1157,18 @@ const res = await fetch('/api/members', {
<div class="hero-progress"> <div class="hero-progress">
<span class="hero-pct">{weeklyTotal > 0 ? weeklyPct : 0}%</span> <span class="hero-pct">{weeklyTotal > 0 ? weeklyPct : 0}%</span>
<div class="bar-wrap hero-bar"> <div class="hero-bars">
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div> <div class="bar-wrap hero-bar">
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div>
</div>
{#if pmTargetPct != null}
<div class="hero-target">
<div class="bar-wrap hero-target-bar">
<div class="bar-fill hero-target-fill" style="width:{pmTargetPct}%"></div>
</div>
<span class="hero-target-lbl">🎯 {pmTargetPct}%</span>
</div>
{/if}
</div> </div>
<span class="hero-count"> <span class="hero-count">
{weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'} {weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'}
@@ -1209,6 +1231,9 @@ const res = await fetch('/api/members', {
</div> </div>
<div class="goal-bar-wrap"> <div class="goal-bar-wrap">
<div class="goal-bar-fill" style="width:{pct}%"></div> <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>
<div class="goal-foot"> <div class="goal-foot">
{#if goal.isPercent && goal.totalPotential === 0} {#if goal.isPercent && goal.totalPotential === 0}
@@ -1222,11 +1247,12 @@ const res = await fetch('/api/members', {
>{Math.min(goal.current, goal.target)} / {goal.target}</span >{Math.min(goal.current, goal.target)} / {goal.target}</span
> >
{/if} {/if}
{#if goal.achieved} {#if goal.achieved}
<span class="goal-badge">✅ earned</span> <span class="goal-badge">✅ earned</span>
{:else} {:else}
<span class="goal-pct">{pct}%</span> <span class="goal-target-pill">🎯 {goal.targetLabel}</span>
{/if} <span class="goal-pct">{pct}%</span>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -1293,19 +1319,24 @@ const res = await fetch('/api/members', {
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo} {#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
{@const urgency = todoUrgency(todo)} {@const urgency = todoUrgency(todo)}
{#if todo.type === 'emoji'} {#if todo.type === 'emoji'}
<div <div
class="chore todo-chore todo-display" class="chore todo-chore todo-display"
class:todo-blue={urgency === 'blue'} class:todo-blue={urgency === 'blue'}
class:todo-green={urgency === 'green'} class:todo-green={urgency === 'green'}
class:todo-orange={urgency === 'orange'} class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'} class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'} class:todo-black={urgency === 'black'}
> >
<span class="checkbox">📋</span> <span class="checkbox">📋</span>
<span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span> <span class="chore-name">{todo.customName || 'Todo'}</span>
<span class="todo-label">todo</span> {#if todo.completeBy}
<span class="chore-value">{todo.emoji || '🎯'}</span> <span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
</div> {/if}
</span>
<span class="todo-label">todo</span>
<span class="chore-value">{todo.emoji || '🎯'}</span>
</div>
{:else} {:else}
<button <button
class="chore todo-chore" class="chore todo-chore"
@@ -1318,7 +1349,12 @@ const res = await fetch('/api/members', {
disabled={!!togglingIds} disabled={!!togglingIds}
> >
<span class="checkbox">📋</span> <span class="checkbox">📋</span>
<span class="chore-name">{todo.customName || 'Todo'}</span> <span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span>
{#if todo.completeBy}
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
{/if}
</span>
<span class="todo-label">todo</span> <span class="todo-label">todo</span>
<span class="chore-value"> <span class="chore-value">
{todo.type === 'money' {todo.type === 'money'
@@ -1530,18 +1566,39 @@ const res = await fetch('/api/members', {
.done-list { .done-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0.4rem 0 0;
display: flex;
flex-direction: column;
gap: 0;
} }
.done-list li { .done-item {
font-size: 0.75rem;
padding: 0.05rem 0;
color: #059669;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
font-size: 0.8rem;
color: #059669;
}
.done-item.odd {
background: #f3f4f6;
}
.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 { .revoke-form {
display: inline; margin-left: auto;
display: inline-flex;
flex-shrink: 0;
} }
.revoke-btn { .revoke-btn {
background: none; background: none;
@@ -1792,6 +1849,41 @@ const res = await fetch('/api/members', {
.hero-fill { .hero-fill {
background: #fff; background: #fff;
} }
.hero-bars {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
justify-content: center;
min-width: 0;
}
.hero-bars .hero-bar {
flex: none;
width: 100%;
}
.hero-target {
display: flex;
align-items: center;
gap: 0.45rem;
}
.hero-target-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.22);
}
.hero-target-fill {
height: 100%;
background: #fde047;
border-radius: 4px;
transition: width 0.3s ease;
}
.hero-target-lbl {
font-size: 0.7rem;
font-weight: 700;
color: #fde047;
white-space: nowrap;
flex-shrink: 0;
}
.hero-count { .hero-count {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
@@ -1944,17 +2036,40 @@ const res = await fetch('/api/members', {
color: #059669; color: #059669;
} }
.goal-bar-wrap { .goal-bar-wrap {
position: relative;
height: 8px; height: 8px;
background: #f3f4f6; background: #f3f4f6;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: visible;
margin-bottom: 0.35rem; margin: 0.35rem 0 0.5rem;
} }
.goal-bar-fill { .goal-bar-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6); background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px; border-radius: 4px;
transition: width 0.3s ease; transition: width 0.3s ease;
max-width: 100%;
}
.goal-target {
position: absolute;
top: -4px;
bottom: -4px;
width: 3px;
background: #ef4444;
border-radius: 2px;
transform: translateX(-50%);
z-index: 2;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8);
}
.goal-target-pill {
font-size: 0.72rem;
font-weight: 700;
color: #ef4444;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 999px;
padding: 1px 8px;
white-space: nowrap;
} }
.goal-achieved .goal-bar-fill { .goal-achieved .goal-bar-fill {
background: linear-gradient(90deg, #10b981, #34d399); background: linear-gradient(90deg, #10b981, #34d399);
@@ -2104,6 +2219,20 @@ const res = await fetch('/api/members', {
padding: 1px 5px; padding: 1px 5px;
flex-shrink: 0; 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-display { .todo-display {
cursor: default; cursor: default;
opacity: 0.85; opacity: 0.85;
@@ -4,6 +4,7 @@
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card } from '$lib/components'; import { ViewHeader, CardGrid, Card } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { choresIcon, todoIcon } from '$lib/components/icons';
import { formatHumanDate } from '$lib/format'; import { formatHumanDate } from '$lib/format';
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons'; import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types'; import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
@@ -305,21 +306,12 @@
<p class="error">{form.error}</p> <p class="error">{form.error}</p>
{/if} {/if}
<CardGrid> <CardGrid stackTablet>
<!-- Templates Section (at top, like bonuses) --> <!-- Templates Section (at top, like bonuses) -->
<Card cols={3}> <Card cols={3}>
<div class="template-section"> <div class="template-section">
<div class="template-header"> <div class="template-header">
<h3>Templates — Drag to assign</h3> <h3>Templates — Drag to assign</h3>
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each activeSeasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
</div> </div>
<div class="template-list"> <div class="template-list">
{#each templates as t} {#each templates as t}
@@ -349,11 +341,22 @@
<!-- Member Chore Assignment Kanban (3-column grid) --> <!-- Member Chore Assignment Kanban (3-column grid) -->
<Card cols={3} scrollX> <Card cols={3} scrollX>
<div class="kanban-toolbar">
<div class="season-filter">
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each activeSeasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
</div>
</div>
<div class="kanban"> <div class="kanban">
{#each members as m} {#each members as m}
<div <div
class="column member-col" class="column member-col"
style="border-top-color: {m.color}" style="border-top-color: {m.color}; --mc: {m.color}"
ondragover={handleDragOver} ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, m.id)} ondrop={(e) => handleDrop(e, m.id)}
> >
@@ -363,13 +366,17 @@
</h3> </h3>
<!-- TODOS accordion wrapper --> <!-- TODOS accordion wrapper -->
<div class="accordion-wrapper accordion-todos"> <div class="accordion-wrapper accordion-todos" class:open={accordionState[m.id]?.todos ?? true}>
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}> <button class="accordion-head" onclick={() => toggleAccordion(m.id, 'todos')}>
<span class="accordion-label">📋 Todos ({todosForMember(m.id).length})</span> <span class="accordion-title">
<span class="accordion-icon">{@html todoIcon}</span>
<span class="accordion-label">Todos</span>
</span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span >{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
</button> </button>
<span class="accordion-count" style="background:{m.color}">{todosForMember(m.id).length}</span>
{#if accordionState[m.id]?.todos ?? true} {#if accordionState[m.id]?.todos ?? true}
<div class="accordion-body"> <div class="accordion-body">
{#each sortedTodosForMember(m.id) as a} {#each sortedTodosForMember(m.id) as a}
@@ -433,13 +440,17 @@
</div> </div>
<!-- CHORES accordion wrapper --> <!-- CHORES accordion wrapper -->
<div class="accordion-wrapper accordion-chores"> <div class="accordion-wrapper accordion-chores" class:open={accordionState[m.id]?.chores ?? true}>
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}> <button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-label">Chores ({assignedForMember(m.id).length})</span> <span class="accordion-title">
<span class="accordion-icon">{@html choresIcon}</span>
<span class="accordion-label">Chores</span>
</span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span >{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
</button> </button>
<span class="accordion-count" style="background:{m.color}">{assignedForMember(m.id).length}</span>
{#if accordionState[m.id]?.chores ?? true} {#if accordionState[m.id]?.chores ?? true}
<div class="accordion-body"> <div class="accordion-body">
{#each assignedForMember(m.id) as a} {#each assignedForMember(m.id) as a}
@@ -455,11 +466,8 @@
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} /> <TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<strong>{a.customName || tName}</strong> <strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span> <span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#each a.seasonIds || [] as sid} {#each a.seasonIds || [] as sid}
<span class="badge season" style="border-color:{seasonColor(sid)}; color:{seasonColor(sid)}" <span class="season-dot" title={seasonName(sid)} style="background:{seasonColor(sid)}"></span>
>{seasonName(sid)}</span
>
{/each} {/each}
{#if (a.seasonIds || []).length === 0} {#if (a.seasonIds || []).length === 0}
<span class="badge season muted">All year</span> <span class="badge season muted">All year</span>
@@ -874,6 +882,14 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
} }
@media (max-width: 1023px) {
.template-list {
flex-direction: column;
}
.template-list .card.template {
width: 100%;
}
}
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */ /* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban { .kanban {
@@ -1038,6 +1054,11 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.kanban-toolbar {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.season-filter label { .season-filter label {
font-size: 0.8rem; font-size: 0.8rem;
color: #6b7280; color: #6b7280;
@@ -1059,6 +1080,14 @@
color: #9ca3af; color: #9ca3af;
border-color: #e5e7eb; border-color: #e5e7eb;
} }
.season-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
flex: none;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.card-meta { .card-meta {
font-size: 0.7rem; font-size: 0.7rem;
color: #9ca3af; color: #9ca3af;
@@ -1234,37 +1263,45 @@
/* ── Accordion wrappers ── */ /* ── Accordion wrappers ── */
.accordion-wrapper { .accordion-wrapper {
background: #6366f1; position: relative;
border-radius: 10px;
margin: 0.75rem 0 0.3rem; margin: 0.75rem 0 0.3rem;
overflow: hidden;
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.25);
} }
.accordion-wrapper.accordion-todos { .accordion-wrapper:first-child {
background: #2e8b57; margin-top: 1.1rem;
box-shadow: 0 2px 6px rgba(46, 139, 87, 0.25);
} }
.accordion-head { .accordion-head {
width: 100%; width: 100%;
padding: 0.55rem 0.85rem; padding: 0.55rem 0.85rem;
border: none; border: none;
border-left: 4px solid rgba(0, 0, 0, 0.2); border-left: 4px solid rgba(0, 0, 0, 0.2);
background: transparent; background: var(--mc, #6366f1);
border-radius: 10px;
cursor: pointer; cursor: pointer;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 700; font-weight: 700;
color: #fff; color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
transition: filter 0.15s; transition: filter 0.15s;
} }
.accordion-wrapper.accordion-todos .accordion-head {
background: oklch(from var(--mc, #6366f1) calc(l + 0.08) c h);
}
.accordion-wrapper.open .accordion-head {
border-radius: 10px 10px 0 0;
}
.accordion-head:hover { .accordion-head:hover {
filter: brightness(1.1); filter: brightness(1.1);
} }
.accordion-body { .accordion-body {
padding: 0.5rem 0.65rem 0.65rem; padding: 0.5rem 0.65rem 0.65rem;
background: rgba(0, 0, 0, 0.08); background: oklch(from var(--mc, #6366f1) calc(l - 0.1) c h);
border-radius: 0 0 10px 10px;
}
.accordion-wrapper.accordion-todos .accordion-body {
background: oklch(from var(--mc, #6366f1) calc(l - 0.02) c h);
} }
.accordion-chevron { .accordion-chevron {
display: flex; display: flex;
@@ -1272,6 +1309,40 @@
flex-shrink: 0; flex-shrink: 0;
opacity: 0.8; opacity: 0.8;
} }
.accordion-icon {
display: inline-flex;
align-items: center;
}
.accordion-title {
display: flex;
align-items: center;
gap: 0.4rem;
}
.accordion-label {
font-size: 0.82rem;
font-weight: 700;
color: #fff;
}
.accordion-count {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: #6366f1;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
z-index: 2;
}
/* ── Todo admin cards ── */ /* ── Todo admin cards ── */
.todo-admin-card { .todo-admin-card {
@@ -131,7 +131,7 @@
<div class="toast">{toast}</div> <div class="toast">{toast}</div>
{/if} {/if}
<CardGrid> <CardGrid stackTablet>
{#if activeTab === 'rewards'} {#if activeTab === 'rewards'}
<Card cols={3}> <Card cols={3}>
{#if rewards.length === 0} {#if rewards.length === 0}
@@ -5,6 +5,7 @@
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { chevronRight } from '$lib/components/icons';
import Clock from '@lucide/svelte/icons/clock'; import Clock from '@lucide/svelte/icons/clock';
import type { import type {
BonusConfig, BonusConfig,
@@ -21,6 +22,13 @@
let editingConfig = $state<BonusConfig | null>(null); let editingConfig = $state<BonusConfig | null>(null);
let creatingFromTemplate = $state<BonusTemplate | null>(null); let creatingFromTemplate = $state<BonusTemplate | null>(null);
// Kanban columns can collapse (accordion) so templates can be dragged onto
// the desired area on tablet/mobile without cramming three columns.
let collapsed = $state({ member: false, comp: false, collab: false });
function toggleCol(key: 'member' | 'comp' | 'collab') {
collapsed[key] = !collapsed[key];
}
let createVals = $state({ let createVals = $state({
name: '', name: '',
description: '', description: '',
@@ -210,10 +218,10 @@
); );
</script> </script>
<ViewHeader title="Bonuses" hero /> <ViewHeader title="Rewards" hero />
{#if pocketNotice} {#if pocketNotice}
<CardGrid> <CardGrid stackTablet>
<Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}> <Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}>
<div class="pocket-notice"> <div class="pocket-notice">
<strong>Pocket Money</strong> <strong>Pocket Money</strong>
@@ -231,7 +239,7 @@
</CardGrid> </CardGrid>
{/if} {/if}
<CardGrid> <CardGrid stackTablet>
<Card cols={3}> <Card cols={3}>
<div class="template-section"> <div class="template-section">
<h3>Templates — Drag to assign</h3> <h3>Templates — Drag to assign</h3>
@@ -267,8 +275,12 @@
<Card cols={3} scrollX> <Card cols={3} scrollX>
<div class="kanban"> <div class="kanban">
<!-- Member Column --> <!-- Member Column -->
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}> <div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}>
<h3>Member</h3> <button class="col-header" type="button" onclick={() => toggleCol('member')}>
<span class="col-title">Member</span>
<span class="chev" class:open={!collapsed.member}>{@html chevronRight}</span>
</button>
<div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg} {#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg}
<div <div
class="col-card" class="col-card"
@@ -333,11 +345,16 @@
{#if allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)).length === 0} {#if allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)).length === 0}
<p class="empty">No bonuses</p> <p class="empty">No bonuses</p>
{/if} {/if}
</div>
</div> </div>
<!-- Competition Column --> <!-- Competition Column -->
<div class="column comp-col" ondragover={handleDragOver} ondrop={handleDropToCompetition}> <div class="column comp-col" class:collapsed={collapsed.comp} ondragover={handleDragOver} ondrop={handleDropToCompetition}>
<h3>Competition</h3> <button class="col-header" type="button" onclick={() => toggleCol('comp')}>
<span class="col-title">Competition</span>
<span class="chev" class:open={!collapsed.comp}>{@html chevronRight}</span>
</button>
<div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
{@const prog = progressByConfigId.get(cfg.id)} {@const prog = progressByConfigId.get(cfg.id)}
<div <div
@@ -394,11 +411,16 @@
{#if allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)).length === 0} {#if allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)).length === 0}
<p class="empty">No bonuses</p> <p class="empty">No bonuses</p>
{/if} {/if}
</div>
</div> </div>
<!-- Collaboration Column --> <!-- Collaboration Column -->
<div class="column collab-col" ondragover={handleDragOver} ondrop={handleDropToCollaboration}> <div class="column collab-col" class:collapsed={collapsed.collab} ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
<h3>Collaboration</h3> <button class="col-header" type="button" onclick={() => toggleCol('collab')}>
<span class="col-title">Collaboration</span>
<span class="chev" class:open={!collapsed.collab}>{@html chevronRight}</span>
</button>
<div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
{@const prog = progressByConfigId.get(cfg.id)} {@const prog = progressByConfigId.get(cfg.id)}
<div <div
@@ -457,6 +479,7 @@
{#if allBonusConfigs.filter((c) => c.target === 'collaborative' && !doneConfigs.includes(c)).length === 0} {#if allBonusConfigs.filter((c) => c.target === 'collaborative' && !doneConfigs.includes(c)).length === 0}
<p class="empty">No bonuses</p> <p class="empty">No bonuses</p>
{/if} {/if}
</div>
</div> </div>
</div> </div>
@@ -721,11 +744,23 @@
cursor: grabbing; cursor: grabbing;
opacity: 0.8; opacity: 0.8;
} }
@media (max-width: 1023px) {
.template-list {
flex-direction: column;
}
.template-card {
width: 100%;
}
}
.kanban { .kanban {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 0.75rem; gap: 0.75rem;
min-width: 450px; }
@media (max-width: 1023px) {
.kanban {
grid-template-columns: 1fr;
}
} }
.column { .column {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
@@ -742,8 +777,40 @@
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 0.95rem; font-size: 0.95rem;
} }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin: 0 0 0.5rem;
padding: 0;
background: none;
border: none;
font: inherit;
text-align: left;
cursor: pointer;
color: inherit;
}
.col-header .col-title {
font-size: 0.95rem;
font-weight: 700;
}
.chev {
display: inline-flex;
transition: transform 0.2s ease;
}
.chev.open {
transform: rotate(90deg);
}
.column.collapsed {
min-height: 0;
}
.column.collapsed .col-body {
display: none;
}
.card-head { .card-head {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
+1 -1
View File
@@ -339,7 +339,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
rel("templateId", ids.chore_templates), rel("templateId", ids.chore_templates),
select("frequency", ["daily", "weekly"], true), select("frequency", ["daily", "weekly"], true),
select("type", ["points", "money", "emoji"], true), select("type", ["points", "money", "emoji"], true),
number("value", true), number("value", false),
text("customName"), text("customName"),
text("description"), text("description"),
text("icon"), text("icon"),