add more ux and ui patches with extra login helper
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
let { cols = 3, children }: { cols?: number; children?: any } = $props();
|
||||
let { stackTablet = false, children }: { stackTablet?: boolean; children?: any } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card-grid" style="--grid-cols: {cols}">
|
||||
<div class="card-grid" class:stack-tablet={stackTablet}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -10,18 +10,22 @@
|
||||
.card-grid {
|
||||
display: grid;
|
||||
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) {
|
||||
.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 */
|
||||
@media (max-width: 639px) {
|
||||
.card-grid {
|
||||
--grid-cols: 1;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import {
|
||||
dashboardIcon,
|
||||
choresIcon,
|
||||
rewardsIcon,
|
||||
bonusesIcon,
|
||||
archiveIcon,
|
||||
gemIcon,
|
||||
homeIcon,
|
||||
settingsIcon,
|
||||
logoutIcon,
|
||||
@@ -30,8 +30,8 @@
|
||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: rewardsIcon },
|
||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon }
|
||||
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: gemIcon },
|
||||
{ href: `/${famSlug}/${memberName}/ledger`, label: 'Ledger', icon: archiveIcon }
|
||||
]
|
||||
: showChildItems
|
||||
? [
|
||||
|
||||
@@ -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 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 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 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>'
|
||||
@@ -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 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 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>'
|
||||
|
||||
@@ -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
|
||||
// season picker always has options. Idempotent by name.
|
||||
// Seed a default season (Holidays) per family so the family-admin season
|
||||
// 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> {
|
||||
const famsCol = await getCollection("fams");
|
||||
if (!famsCol) return;
|
||||
@@ -78,18 +82,19 @@ async function ensureDefaultSeasons(): Promise<void> {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
const fams = (await famsRes.json()).items || [];
|
||||
const defaults = [
|
||||
{ name: "Holidays", color: "#f59e0b", active: true },
|
||||
{ name: "Term time", color: "#3b82f6", active: true },
|
||||
];
|
||||
const defaults = [{ name: "Holidays", color: "#f59e0b", active: true }];
|
||||
for (const fam of fams) {
|
||||
const sRes = await fetch(
|
||||
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${t}` } }
|
||||
);
|
||||
const have = new Set(((await sRes.json()).items || []).map((s: any) => s.name));
|
||||
for (const d of defaults) {
|
||||
if (!have.has(d.name)) {
|
||||
const have = ((await sRes.json()).items || []) as any[];
|
||||
// Only seed defaults for families that have no seasons at all. Seeding
|
||||
// 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 });
|
||||
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class ChatStore {
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private lastSeenAt = 0;
|
||||
private typingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private audioCtx: AudioContext | null = null;
|
||||
|
||||
typingNames = $derived.by(() => {
|
||||
const names: string[] = [];
|
||||
@@ -156,8 +157,9 @@ class ChatStore {
|
||||
}
|
||||
// Stop showing their typing indicator once the message lands.
|
||||
this.clearTyping(record.authorId);
|
||||
if (record.authorId !== this.actorId && !this.open) {
|
||||
this.unread++;
|
||||
if (record.authorId !== this.actorId) {
|
||||
if (!this.open) this.unread++;
|
||||
this.playDunk();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +186,39 @@ class ChatStore {
|
||||
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) {
|
||||
if (!this.typing[actorId]) return;
|
||||
const next = { ...this.typing };
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from '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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -11,6 +21,18 @@
|
||||
</svelte:head>
|
||||
|
||||
<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 -->
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
@@ -110,6 +132,58 @@
|
||||
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 {
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #ffffff 55%, #eef2ff 100%);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { notices } from '$lib/stores/notices.svelte';
|
||||
import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
|
||||
import { chatIcon } from '$lib/components/icons';
|
||||
import { recordShortcut } from '$lib/shortcut';
|
||||
import type { Session } from '$lib/types';
|
||||
|
||||
let { children, data } = $props();
|
||||
@@ -97,11 +98,24 @@
|
||||
return map[reason] || 'Access paused.';
|
||||
}
|
||||
|
||||
// Claim toast watcher (admin only)
|
||||
// Claim toast watcher (admin only)
|
||||
let claimToast = $state('');
|
||||
let prevClaimedIds = new Set<string>();
|
||||
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(() => {
|
||||
if (!session || !famStore.initialized) return;
|
||||
const rewards = famStore.rewards;
|
||||
|
||||
@@ -42,6 +42,27 @@
|
||||
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(() => {
|
||||
if (!summary?.summaries?.length) return;
|
||||
|
||||
@@ -69,25 +90,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of summary.summaries) {
|
||||
const canvas = donutCanvases[s.memberId];
|
||||
if (!canvas) continue;
|
||||
const done = s.choresCompleted || 0;
|
||||
const total = s.totalChores || 0;
|
||||
function makeDonut(canvas: HTMLCanvasElement, done: number, total: number, color: string) {
|
||||
const remaining = Math.max(0, total - done);
|
||||
donutCharts[s.memberId] = new Chart(canvas, {
|
||||
return new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Done', 'Remaining'],
|
||||
datasets: [{
|
||||
data: [done, remaining],
|
||||
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'],
|
||||
backgroundColor: [color || '#22c55e', '#e5e7eb'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
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(() => {
|
||||
@@ -97,7 +135,7 @@
|
||||
</script>
|
||||
|
||||
<ViewHeader
|
||||
title={famSlug}
|
||||
title={page.data.fam?.name || famSlug}
|
||||
subtitle={summary?.weekStart ? `Week of ${formatDDMMYY(summary.weekStart)}` : 'Dashboard'}
|
||||
hero
|
||||
/>
|
||||
@@ -131,20 +169,30 @@
|
||||
<div class="kanban">
|
||||
{#each summary.summaries as s}
|
||||
{@const m = members.find((m2: any) => m2.id === s.memberId)}
|
||||
{@const total = totalChoresFor(s.memberId)}
|
||||
{@const pct = total > 0 ? Math.round((s.choresCompleted / total) * 100) : 0}
|
||||
{@const wkTotal = weeklyPotentialFor(s.memberId)}
|
||||
{@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="member-hero">
|
||||
<div class="member-head">
|
||||
<span class="dot" style="background:{s.memberColor}"></span>
|
||||
<span class="member-name">{s.memberName}</span>
|
||||
{#if m}
|
||||
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
||||
{/if}
|
||||
<div class="member-head">
|
||||
<span class="dot" style="background:{s.memberColor}"></span>
|
||||
<span class="member-name">{s.memberName}</span>
|
||||
{#if m}
|
||||
<a href="/{famSlug}/{handleOf(m.username)}" class="kanban-link">Kanban →</a>
|
||||
{/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 class="donut-wrap">
|
||||
<canvas use:captureCanvas={s.memberId} height="100" width="100"></canvas>
|
||||
<div class="donut-center" style="color:{s.memberColor}">{pct}%</div>
|
||||
<canvas use:captureCanvas={`${s.memberId}:week`} height="100" width="100"></canvas>
|
||||
<div class="donut-center" style="color:{s.memberColor}">{wkPct}%</div>
|
||||
<span class="donut-label">Week</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-stats">
|
||||
@@ -158,12 +206,7 @@
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-lbl">Done</span>
|
||||
<span class="stat-val">{s.choresCompleted}/{total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<div class="bar-wrap">
|
||||
<div class="bar-fill" style="width:{pct}%"></div>
|
||||
<span class="stat-val">{wkDone}/{wkTotal}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,8 +293,9 @@
|
||||
|
||||
.member-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.member-head {
|
||||
@@ -283,13 +327,19 @@
|
||||
}
|
||||
.kanban-link:hover { text-decoration: underline; }
|
||||
|
||||
.donuts {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.85rem;
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
}
|
||||
.donut-wrap {
|
||||
position: relative;
|
||||
width: 70px; height: 70px;
|
||||
width: 62px; height: 62px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.donut-wrap canvas {
|
||||
max-width: 70px; max-height: 70px;
|
||||
max-width: 62px; max-height: 62px;
|
||||
}
|
||||
.donut-center {
|
||||
position: absolute;
|
||||
@@ -297,10 +347,22 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { memberApi } from '$lib/client/api';
|
||||
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
||||
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 { handleOf } from '@shared/slugify';
|
||||
import {
|
||||
@@ -64,10 +65,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
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
|
||||
@@ -332,10 +329,17 @@
|
||||
);
|
||||
|
||||
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;
|
||||
// Must match the pocket-money % threshold definition: all assigned
|
||||
// non-todo chores for the member (no season filter), weighted by the
|
||||
// number of days in the displayed period — so the hero total agrees
|
||||
// with the Goals progress instead of showing a different denominator.
|
||||
const chores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
|
||||
const 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(
|
||||
@@ -349,6 +353,17 @@
|
||||
let weeklyDone = $derived(weekCompletions.length);
|
||||
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0);
|
||||
|
||||
// Pocket-money target as a % of chores, for the layered target bar under the
|
||||
// hero progress. Only meaningful for a percent threshold (points targets
|
||||
// can't be expressed as a fraction of chores).
|
||||
let pmTargetPct = $derived.by(() => {
|
||||
const pm = bonusConfigs.find(
|
||||
(c) => c.isPocketMoney && c.status === 'active' && (!c.memberId || c.memberId === memberId)
|
||||
);
|
||||
if (!pm || pm.thresholdType !== 'percent') return null;
|
||||
return Math.min(100, Math.max(0, Number(pm.criteriaValue) || 0));
|
||||
});
|
||||
|
||||
let weekRewards = $derived(
|
||||
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
|
||||
);
|
||||
@@ -505,10 +520,15 @@
|
||||
? totalPotential > 0 && ratio * 100 >= configured
|
||||
: target > 0 && current >= target;
|
||||
|
||||
return {
|
||||
config: cfg,
|
||||
current,
|
||||
criteriaValue: configured,
|
||||
const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts';
|
||||
const targetLabel = `${configured} ${unit}`;
|
||||
const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100;
|
||||
return {
|
||||
config: cfg,
|
||||
current,
|
||||
criteriaValue: configured,
|
||||
targetPct,
|
||||
targetLabel,
|
||||
target,
|
||||
totalPotential,
|
||||
isPercent,
|
||||
@@ -705,13 +725,11 @@
|
||||
|
||||
<div class="kanban-scroll">
|
||||
<div class="kanban-inner">
|
||||
<CardGrid>
|
||||
<CardGrid stackTablet>
|
||||
<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)}
|
||||
{@const pm = parentBonusConfigs.find(
|
||||
(c: any) => c.isPocketMoney && c.memberId === m.id
|
||||
@@ -722,7 +740,7 @@
|
||||
<span class="member-name">{m.name}</span>
|
||||
{#if !(pm && pm.rewardValue)}
|
||||
<Button
|
||||
href="/{famSlug}/{username}/bonuses?s=pocketmoney"
|
||||
href="/{famSlug}/{username}/rewards?s=pocketmoney"
|
||||
variant="secondary"
|
||||
size="sm">Pocket money</Button
|
||||
>
|
||||
@@ -742,18 +760,12 @@
|
||||
<span class="unset">Pocket money: amount not set</span>
|
||||
{/if}
|
||||
</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)}
|
||||
{#each todays as c, i}
|
||||
<li class="done-item" class:odd={i % 2 === 1}>
|
||||
<span class="done-icon">{@html checkCircleIcon}</span>
|
||||
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
@@ -763,7 +775,7 @@
|
||||
class="revoke-form"
|
||||
>
|
||||
<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>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -1145,8 +1157,18 @@ const res = await fetch('/api/members', {
|
||||
|
||||
<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 class="hero-bars">
|
||||
<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>
|
||||
<span class="hero-count">
|
||||
{weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'}
|
||||
@@ -1209,6 +1231,9 @@ const res = await fetch('/api/members', {
|
||||
</div>
|
||||
<div class="goal-bar-wrap">
|
||||
<div class="goal-bar-fill" style="width:{pct}%"></div>
|
||||
{#if goal.targetPct < 100}
|
||||
<div class="goal-target" style="left:{goal.targetPct}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="goal-foot">
|
||||
{#if goal.isPercent && goal.totalPotential === 0}
|
||||
@@ -1222,11 +1247,12 @@ const res = await fetch('/api/members', {
|
||||
>{Math.min(goal.current, goal.target)} / {goal.target}</span
|
||||
>
|
||||
{/if}
|
||||
{#if goal.achieved}
|
||||
<span class="goal-badge">✅ earned</span>
|
||||
{:else}
|
||||
<span class="goal-pct">{pct}%</span>
|
||||
{/if}
|
||||
{#if goal.achieved}
|
||||
<span class="goal-badge">✅ earned</span>
|
||||
{:else}
|
||||
<span class="goal-target-pill">🎯 {goal.targetLabel}</span>
|
||||
<span class="goal-pct">{pct}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1293,19 +1319,24 @@ const res = await fetch('/api/members', {
|
||||
{#each memberTodos.filter((t) => !isTodoDone(t.id)) 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>
|
||||
<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="todo-main">
|
||||
<span class="chore-name">{todo.customName || 'Todo'}</span>
|
||||
<span class="todo-label">todo</span>
|
||||
<span class="chore-value">{todo.emoji || '🎯'}</span>
|
||||
</div>
|
||||
{#if todo.completeBy}
|
||||
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="todo-label">todo</span>
|
||||
<span class="chore-value">{todo.emoji || '🎯'}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="chore todo-chore"
|
||||
@@ -1318,7 +1349,12 @@ const res = await fetch('/api/members', {
|
||||
disabled={!!togglingIds}
|
||||
>
|
||||
<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="chore-value">
|
||||
{todo.type === 'money'
|
||||
@@ -1530,18 +1566,39 @@ const res = await fetch('/api/members', {
|
||||
.done-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin: 0.4rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.done-list li {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0;
|
||||
color: #059669;
|
||||
.done-item {
|
||||
display: flex;
|
||||
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 {
|
||||
display: inline;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.revoke-btn {
|
||||
background: none;
|
||||
@@ -1792,6 +1849,41 @@ const res = await fetch('/api/members', {
|
||||
.hero-fill {
|
||||
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 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
@@ -1944,17 +2036,40 @@ const res = await fetch('/api/members', {
|
||||
color: #059669;
|
||||
}
|
||||
.goal-bar-wrap {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.35rem;
|
||||
overflow: visible;
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
}
|
||||
.goal-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
max-width: 100%;
|
||||
}
|
||||
.goal-target {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
bottom: -4px;
|
||||
width: 3px;
|
||||
background: #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 {
|
||||
background: linear-gradient(90deg, #10b981, #34d399);
|
||||
@@ -2104,6 +2219,20 @@ const res = await fetch('/api/members', {
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.todo-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
.todo-due {
|
||||
font-size: 0.68rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.todo-display {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card } from '$lib/components';
|
||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||
import { choresIcon, todoIcon } from '$lib/components/icons';
|
||||
import { formatHumanDate } from '$lib/format';
|
||||
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
|
||||
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
|
||||
@@ -305,21 +306,12 @@
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<CardGrid>
|
||||
<CardGrid stackTablet>
|
||||
<!-- Templates Section (at top, like bonuses) -->
|
||||
<Card cols={3}>
|
||||
<div class="template-section">
|
||||
<div class="template-header">
|
||||
<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 class="template-list">
|
||||
{#each templates as t}
|
||||
@@ -349,11 +341,22 @@
|
||||
|
||||
<!-- Member Chore Assignment Kanban (3-column grid) -->
|
||||
<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">
|
||||
{#each members as m}
|
||||
<div
|
||||
class="column member-col"
|
||||
style="border-top-color: {m.color}"
|
||||
style="border-top-color: {m.color}; --mc: {m.color}"
|
||||
ondragover={handleDragOver}
|
||||
ondrop={(e) => handleDrop(e, m.id)}
|
||||
>
|
||||
@@ -363,13 +366,17 @@
|
||||
</h3>
|
||||
|
||||
<!-- 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')}>
|
||||
<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"
|
||||
>{@html (accordionState[m.id]?.todos ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>
|
||||
</button>
|
||||
<span class="accordion-count" style="background:{m.color}">{todosForMember(m.id).length}</span>
|
||||
{#if accordionState[m.id]?.todos ?? true}
|
||||
<div class="accordion-body">
|
||||
{#each sortedTodosForMember(m.id) as a}
|
||||
@@ -433,13 +440,17 @@
|
||||
</div>
|
||||
|
||||
<!-- 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')}>
|
||||
<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"
|
||||
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>
|
||||
</button>
|
||||
<span class="accordion-count" style="background:{m.color}">{assignedForMember(m.id).length}</span>
|
||||
{#if accordionState[m.id]?.chores ?? true}
|
||||
<div class="accordion-body">
|
||||
{#each assignedForMember(m.id) as a}
|
||||
@@ -455,11 +466,8 @@
|
||||
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
|
||||
<strong>{a.customName || tName}</strong>
|
||||
<span class="badge">{a.frequency}</span>
|
||||
<span class="badge type">{a.type}</span>
|
||||
{#each a.seasonIds || [] as sid}
|
||||
<span class="badge season" style="border-color:{seasonColor(sid)}; color:{seasonColor(sid)}"
|
||||
>{seasonName(sid)}</span
|
||||
>
|
||||
<span class="season-dot" title={seasonName(sid)} style="background:{seasonColor(sid)}"></span>
|
||||
{/each}
|
||||
{#if (a.seasonIds || []).length === 0}
|
||||
<span class="badge season muted">All year</span>
|
||||
@@ -874,6 +882,14 @@
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -1038,6 +1054,11 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kanban-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.season-filter label {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
@@ -1059,6 +1080,14 @@
|
||||
color: #9ca3af;
|
||||
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 {
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
@@ -1234,37 +1263,45 @@
|
||||
|
||||
/* ── Accordion wrappers ── */
|
||||
.accordion-wrapper {
|
||||
background: #6366f1;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
margin: 0.75rem 0 0.3rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
.accordion-wrapper.accordion-todos {
|
||||
background: #2e8b57;
|
||||
box-shadow: 0 2px 6px rgba(46, 139, 87, 0.25);
|
||||
.accordion-wrapper:first-child {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
.accordion-head {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: none;
|
||||
border-left: 4px solid rgba(0, 0, 0, 0.2);
|
||||
background: transparent;
|
||||
background: var(--mc, #6366f1);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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 {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.accordion-body {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -1272,6 +1309,40 @@
|
||||
flex-shrink: 0;
|
||||
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-card {
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<div class="toast">{toast}</div>
|
||||
{/if}
|
||||
|
||||
<CardGrid>
|
||||
<CardGrid stackTablet>
|
||||
{#if activeTab === 'rewards'}
|
||||
<Card cols={3}>
|
||||
{#if rewards.length === 0}
|
||||
|
||||
+77
-10
@@ -5,6 +5,7 @@
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
|
||||
import { chevronRight } from '$lib/components/icons';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import type {
|
||||
BonusConfig,
|
||||
@@ -21,6 +22,13 @@
|
||||
let editingConfig = $state<BonusConfig | 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({
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -210,10 +218,10 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<ViewHeader title="Bonuses" hero />
|
||||
<ViewHeader title="Rewards" hero />
|
||||
|
||||
{#if pocketNotice}
|
||||
<CardGrid>
|
||||
<CardGrid stackTablet>
|
||||
<Card cols={3} accent={hasUnsetPocket ? '#f59e0b' : '#10b981'}>
|
||||
<div class="pocket-notice">
|
||||
<strong>Pocket Money</strong>
|
||||
@@ -231,7 +239,7 @@
|
||||
</CardGrid>
|
||||
{/if}
|
||||
|
||||
<CardGrid>
|
||||
<CardGrid stackTablet>
|
||||
<Card cols={3}>
|
||||
<div class="template-section">
|
||||
<h3>Templates — Drag to assign</h3>
|
||||
@@ -267,8 +275,12 @@
|
||||
<Card cols={3} scrollX>
|
||||
<div class="kanban">
|
||||
<!-- Member Column -->
|
||||
<div class="column member-col" ondragover={handleDragOver} ondrop={handleDropToMember}>
|
||||
<h3>Member</h3>
|
||||
<div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}>
|
||||
<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}
|
||||
<div
|
||||
class="col-card"
|
||||
@@ -333,11 +345,16 @@
|
||||
{#if allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)).length === 0}
|
||||
<p class="empty">No bonuses</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Competition Column -->
|
||||
<div class="column comp-col" ondragover={handleDragOver} ondrop={handleDropToCompetition}>
|
||||
<h3>Competition</h3>
|
||||
<div class="column comp-col" class:collapsed={collapsed.comp} ondragover={handleDragOver} ondrop={handleDropToCompetition}>
|
||||
<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}
|
||||
{@const prog = progressByConfigId.get(cfg.id)}
|
||||
<div
|
||||
@@ -394,11 +411,16 @@
|
||||
{#if allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)).length === 0}
|
||||
<p class="empty">No bonuses</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collaboration Column -->
|
||||
<div class="column collab-col" ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
|
||||
<h3>Collaboration</h3>
|
||||
<div class="column collab-col" class:collapsed={collapsed.collab} ondragover={handleDragOver} ondrop={handleDropToCollaboration}>
|
||||
<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}
|
||||
{@const prog = progressByConfigId.get(cfg.id)}
|
||||
<div
|
||||
@@ -457,6 +479,7 @@
|
||||
{#if allBonusConfigs.filter((c) => c.target === 'collaborative' && !doneConfigs.includes(c)).length === 0}
|
||||
<p class="empty">No bonuses</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -721,11 +744,23 @@
|
||||
cursor: grabbing;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.template-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
.template-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.kanban {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
min-width: 450px;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.kanban {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.column {
|
||||
border: 1px solid #e5e7eb;
|
||||
@@ -742,8 +777,40 @@
|
||||
margin: 0 0 0.5rem;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
Reference in New Issue
Block a user