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
+74
View File
@@ -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%);
+15 -1
View File
@@ -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;
+92 -30
View File
@@ -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;
+185 -56
View File
@@ -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}
@@ -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;