From 9f335db7b293db3e6f4ff04fab1c160e638a640b Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Thu, 27 Aug 2026 14:13:14 +0100 Subject: [PATCH] add more ux and ui patches with extra login helper --- frontend/src/lib/components/CardGrid.svelte | 18 +- frontend/src/lib/components/Sidebar.svelte | 8 +- frontend/src/lib/components/icons.ts | 8 +- frontend/src/lib/server/migrate.ts | 23 +- frontend/src/lib/shortcut.ts | 26 ++ frontend/src/lib/stores/chat.svelte.ts | 39 ++- frontend/src/routes/+page.svelte | 74 ++++++ frontend/src/routes/[fam]/+layout.svelte | 16 +- frontend/src/routes/[fam]/+page.svelte | 122 ++++++--- .../src/routes/[fam]/[username]/+page.svelte | 241 ++++++++++++++---- .../[fam]/[username]/chores/+page.svelte | 127 +++++++-- .../[fam]/[username]/ledger/+page.svelte | 2 +- .../{bonuses => rewards}/+page.server.ts | 0 .../{bonuses => rewards}/+page.svelte | 87 ++++++- .../progress.json/+server.ts | 0 shared/pb/schema.ts | 2 +- 16 files changed, 643 insertions(+), 150 deletions(-) create mode 100644 frontend/src/lib/shortcut.ts rename frontend/src/routes/[fam]/[username]/{bonuses => rewards}/+page.server.ts (100%) rename frontend/src/routes/[fam]/[username]/{bonuses => rewards}/+page.svelte (92%) rename frontend/src/routes/[fam]/[username]/{bonuses => rewards}/progress.json/+server.ts (100%) diff --git a/frontend/src/lib/components/CardGrid.svelte b/frontend/src/lib/components/CardGrid.svelte index e49ed11..5c6a088 100644 --- a/frontend/src/lib/components/CardGrid.svelte +++ b/frontend/src/lib/components/CardGrid.svelte @@ -1,8 +1,8 @@ -
+
{@render children?.()}
@@ -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; } } - \ No newline at end of file + diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index dbb4956..5db33c0 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -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 ? [ diff --git a/frontend/src/lib/components/icons.ts b/frontend/src/lib/components/icons.ts index d60d98d..7378d23 100644 --- a/frontend/src/lib/components/icons.ts +++ b/frontend/src/lib/components/icons.ts @@ -1,7 +1,11 @@ export const dashboardIcon = '' export const choresIcon = '' export const rewardsIcon = '' -export const bonusesIcon = '' +export const moneyIcon = '' +export const handCoinsIcon = '' +export const gemIcon = '' +export const archiveIcon = '' +export const todoIcon = '' export const homeIcon = '' export const settingsIcon = '' export const logoutIcon = '' @@ -11,3 +15,5 @@ export const chevronRight = ' { } } -// 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 { const famsCol = await getCollection("fams"); if (!famsCol) return; @@ -78,18 +82,19 @@ async function ensureDefaultSeasons(): Promise { 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}`); } diff --git a/frontend/src/lib/shortcut.ts b/frontend/src/lib/shortcut.ts new file mode 100644 index 0000000..52b4921 --- /dev/null +++ b/frontend/src/lib/shortcut.ts @@ -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}`; +} diff --git a/frontend/src/lib/stores/chat.svelte.ts b/frontend/src/lib/stores/chat.svelte.ts index 3b12b67..d66e41b 100644 --- a/frontend/src/lib/stores/chat.svelte.ts +++ b/frontend/src/lib/stores/chat.svelte.ts @@ -40,6 +40,7 @@ class ChatStore { private initPromise: Promise | null = null; private lastSeenAt = 0; private typingTimers = new Map>(); + 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 }; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index b5fc235..2d0afda 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,5 +1,15 @@ @@ -11,6 +21,18 @@
+ {#if shortcut} + +
+ {@html homeIcon} + + + + {shortcut.famName} + Jump back in → + + + {/if}
@@ -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%); diff --git a/frontend/src/routes/[fam]/+layout.svelte b/frontend/src/routes/[fam]/+layout.svelte index c56c275..f6ea736 100644 --- a/frontend/src/routes/[fam]/+layout.svelte +++ b/frontend/src/routes/[fam]/+layout.svelte @@ -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(); 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; diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte index f05f21f..bd83196 100644 --- a/frontend/src/routes/[fam]/+page.svelte +++ b/frontend/src/routes/[fam]/+page.svelte @@ -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 @@ @@ -131,20 +169,30 @@
{#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}
-
-
- - {s.memberName} - {#if m} - Kanban → - {/if} +
+ + {s.memberName} + {#if m} + Kanban → + {/if} +
+
+
+ +
{tdPct}%
+ Today
- -
{pct}%
+ +
{wkPct}%
+ Week
@@ -158,12 +206,7 @@
Done - {s.choresCompleted}/{total} -
-
-
-
-
+ {wkDone}/{wkTotal}
@@ -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; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 4a55c08..735b28a 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -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 @@
- + {#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 @@ {m.name} {#if !(pm && pm.rewardValue)} @@ -742,18 +760,12 @@ Pocket money: amount not set {/if}
-
- Today: -
-
-
- {done}/{total} -
{#if done > 0}
    - {#each todays as c} -
  • - ✅ {choreNameFor(c.assignedChoreId)} + {#each todays as c, i} +
  • + {@html checkCircleIcon} + {choreNameFor(c.assignedChoreId)}
    - +
  • {/each} @@ -1145,8 +1157,18 @@ const res = await fetch('/api/members', {
    {weeklyTotal > 0 ? weeklyPct : 0}% -
    -
    +
    +
    +
    +
    + {#if pmTargetPct != null} +
    +
    +
    +
    + 🎯 {pmTargetPct}% +
    + {/if}
    {weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'} @@ -1209,6 +1231,9 @@ const res = await fetch('/api/members', {
    + {#if goal.targetPct < 100} +
    + {/if}
    {#if goal.isPercent && goal.totalPotential === 0} @@ -1222,11 +1247,12 @@ const res = await fetch('/api/members', { >{Math.min(goal.current, goal.target)} / {goal.target} {/if} - {#if goal.achieved} - ✅ earned - {:else} - {pct}% - {/if} + {#if goal.achieved} + ✅ earned + {:else} + 🎯 {goal.targetLabel} + {pct}% + {/if}
    {/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'} -
    - 📋 +
    + 📋 + {todo.customName || 'Todo'} - todo - {todo.emoji || '🎯'} -
    + {#if todo.completeBy} + due {formatHumanDate(todo.completeBy)} + {/if} + + todo + {todo.emoji || '🎯'} +
    {:else} + {todosForMember(m.id).length} {#if accordionState[m.id]?.todos ?? true}
    {#each sortedTodosForMember(m.id) as a} @@ -433,13 +440,17 @@
    -
    +
    + {assignedForMember(m.id).length} {#if accordionState[m.id]?.chores ?? true}
    {#each assignedForMember(m.id) as a} @@ -455,11 +466,8 @@ {a.customName || tName} {a.frequency} - {a.type} {#each a.seasonIds || [] as sid} - {seasonName(sid)} + {/each} {#if (a.seasonIds || []).length === 0} All year @@ -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 { diff --git a/frontend/src/routes/[fam]/[username]/ledger/+page.svelte b/frontend/src/routes/[fam]/[username]/ledger/+page.svelte index aef2e74..333e485 100644 --- a/frontend/src/routes/[fam]/[username]/ledger/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/ledger/+page.svelte @@ -131,7 +131,7 @@
    {toast}
    {/if} - + {#if activeTab === 'rewards'} {#if rewards.length === 0} diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts similarity index 100% rename from frontend/src/routes/[fam]/[username]/bonuses/+page.server.ts rename to frontend/src/routes/[fam]/[username]/rewards/+page.server.ts diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte similarity index 92% rename from frontend/src/routes/[fam]/[username]/bonuses/+page.svelte rename to frontend/src/routes/[fam]/[username]/rewards/+page.svelte index 3c393ea..96f4c91 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -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(null); let creatingFromTemplate = $state(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 @@ ); - + {#if pocketNotice} - +
    Pocket Money @@ -231,7 +239,7 @@ {/if} - +

    Templates — Drag to assign

    @@ -267,8 +275,12 @@
    -
    -

    Member

    +
    + +
    {#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg}
    c.target === 'individual' && !doneConfigs.includes(c)).length === 0}

    No bonuses

    {/if} +
    -
    -

    Competition

    +
    + +
    {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {@const prog = progressByConfigId.get(cfg.id)}
    c.target === 'competitive' && !doneConfigs.includes(c)).length === 0}

    No bonuses

    {/if} +
    -
    -

    Collaboration

    +
    + +
    {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {@const prog = progressByConfigId.get(cfg.id)}
    c.target === 'collaborative' && !doneConfigs.includes(c)).length === 0}

    No bonuses

    {/if} +
    @@ -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; diff --git a/frontend/src/routes/[fam]/[username]/bonuses/progress.json/+server.ts b/frontend/src/routes/[fam]/[username]/rewards/progress.json/+server.ts similarity index 100% rename from frontend/src/routes/[fam]/[username]/bonuses/progress.json/+server.ts rename to frontend/src/routes/[fam]/[username]/rewards/progress.json/+server.ts diff --git a/shared/pb/schema.ts b/shared/pb/schema.ts index 08f315f..cc7e627 100644 --- a/shared/pb/schema.ts +++ b/shared/pb/schema.ts @@ -339,7 +339,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [ rel("templateId", ids.chore_templates), select("frequency", ["daily", "weekly"], true), select("type", ["points", "money", "emoji"], true), - number("value", true), + number("value", false), text("customName"), text("description"), text("icon"),