loads of mobile ui layout upgrades

This commit is contained in:
JCEEE
2026-09-10 11:27:27 +01:00
parent 097106fa69
commit f802113cd1
17 changed files with 501 additions and 123 deletions
+23 -8
View File
@@ -7,7 +7,15 @@
children, children,
class: className, class: className,
selected = false selected = false
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props(); }: {
cols?: 1 | 2 | 3 | 4 | 5 | 6;
title?: string;
accent?: string;
scrollX?: boolean;
children?: any;
class?: string;
selected?: boolean;
} = $props();
</script> </script>
<div <div
@@ -53,12 +61,6 @@
grid-column: span 2; grid-column: span 2;
} }
} }
/* Mobile (1-col grid): every card is a full row */
@media (max-width: 639px) {
.card {
grid-column: span 1;
}
}
.card-header { .card-header {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6;
@@ -76,4 +78,17 @@
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
</style> /* Mobile (1-col grid): every card is a full row — declared last so it
overrides the base .card/.card-body rules (same specificity). */
@media (max-width: 639px) {
.card {
grid-column: span 1;
background: transparent;
border: none;
}
.card-body {
background: transparent;
padding: 0;
}
}
</style>
+1 -1
View File
@@ -298,7 +298,7 @@
/* Mobile: full-screen fixed layer. */ /* Mobile: full-screen fixed layer. */
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
.chat-panel { .chat-panel {
width: 100vw; width: 100dvw;
} }
} }
.chat-head { .chat-head {
+13
View File
@@ -96,5 +96,18 @@
.footer-aside { .footer-aside {
right: 1rem; right: 1rem;
} }
.footer-row {
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.footer-star {
position: static;
}
.footer-aside {
position: static;
text-align: center;
}
} }
</style> </style>
@@ -147,6 +147,9 @@
} }
.price { margin: 0.55rem 0 0.15rem; line-height: 1; } .price { margin: 0.55rem 0 0.15rem; line-height: 1; }
.amount { font-size: 2.4rem; font-weight: 800; color: #111827; } .amount { font-size: 2.4rem; font-weight: 800; color: #111827; }
@media (max-width: 639px) {
.amount { font-size: 1.5em; }
}
.period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; } .period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; }
.blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; } .blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; }
@@ -11,6 +11,12 @@
prefsIcon prefsIcon
} from './icons'; } from './icons';
import FamDone from './FamDone.svelte'; import FamDone from './FamDone.svelte';
import { famStore } from '$lib/stores/fam.svelte';
// Outstanding claim requests (children asked → parent still to issue).
let requestedClaims = $derived(
famStore.initialized ? famStore.rewards.filter((r: any) => r.status === 'requested').length : 0
);
let { famName = '', session = null, isParent = false, role = 'child' } = $props(); let { famName = '', session = null, isParent = false, role = 'child' } = $props();
@@ -62,6 +68,9 @@
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}> <a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
{@html item.icon} {@html item.icon}
<span class="nav-label">{item.label}</span> <span class="nav-label">{item.label}</span>
{#if isParent && item.label === 'Dashboard' && requestedClaims > 0}
<span class="nav-badge">{requestedClaims}</span>
{/if}
</a> </a>
{/each} {/each}
</nav> </nav>
@@ -147,6 +156,19 @@
margin: 0 0.3rem; margin: 0 0.3rem;
white-space: nowrap; white-space: nowrap;
transition: background 0.15s; transition: background 0.15s;
position: relative;
}
.nav-badge {
position: absolute;
top: 4px;
right: 6px;
background: #f59e0b;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1;
padding: 2px 6px;
border-radius: 999px;
} }
.nav-item:hover { .nav-item:hover {
background: #3730a3; background: #3730a3;
+23 -1
View File
@@ -73,6 +73,14 @@
<style> <style>
.view-header { .view-header {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
flex-wrap: wrap;
}
@media (max-width: 639px) {
/* Stack title above tools so the header doesn't squish on phones */
.view-header {
flex-direction: column;
align-items: stretch;
}
} }
.view-title-group { .view-title-group {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -82,6 +90,11 @@
color: #111827; color: #111827;
margin: 0; margin: 0;
} }
@media (max-width: 639px) {
.view-title {
font-size: 1.25rem;
}
}
.view-subtitle { .view-subtitle {
font-size: 0.9rem; font-size: 0.9rem;
color: #6b7280; color: #6b7280;
@@ -90,11 +103,19 @@
.view-tools { .view-tools {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem;
/* Tools row takes the full header width so the title never gets squeezed */
width: 100%;
}
/* Immediate tools (tab bar / week nav / sort) grow to span the row; they
wrap to their own full-width line only when they don't fit side by side */
.view-tools > * {
flex: 1 1 auto;
} }
.tab-bar { .tab-bar {
display: flex; display: flex;
justify-content: space-between;
gap: 2px; gap: 2px;
background: #f3f4f6; background: #f3f4f6;
border-radius: 6px; border-radius: 6px;
@@ -118,6 +139,7 @@
.week-nav { .week-nav {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
} }
.nav-btn { .nav-btn {
+66 -33
View File
@@ -1,33 +1,66 @@
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>' export const dashboardIcon =
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>' '<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 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 choresIcon =
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>' '<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 coinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>' export const rewardsIcon =
export const walletIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>' '<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 circleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>' export const moneyIcon =
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>' '<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 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 coinsIcon =
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>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></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 walletIcon =
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>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></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 circleIcon =
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>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
export const prefsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' export const handCoinsIcon =
export const chevronLeft = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>' '<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 chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>' export const gemIcon =
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>' '<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 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 archiveIcon =
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>' '<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 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 todoIcon =
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>' '<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 targetIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>' export const homeIcon =
export const calendarIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>' '<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 sunIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>' export const settingsIcon =
export const lockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' '<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 clockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>' export const logoutIcon =
export const giftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
export const chevronLeftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 6 -6 6 6 6"/></svg>' export const prefsIcon =
export const chevronRightIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m9 6 6 6 -6 6"/></svg>' '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
export const alertTriangleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>' export const chevronLeft =
export const xCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>' '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>';
export const infoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>' export const chevronRight =
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
export const bellIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
export const chatIcon =
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
export const 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>';
export const targetIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
export const calendarIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
export const sunIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
export const lockIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
export const clockIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
export const giftIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>';
export const chevronLeftIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 6 -6 6 6 6"/></svg>';
export const chevronRightIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 6 6 6 -6 6"/></svg>';
export const alertTriangleIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>';
export const xCircleIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>';
export const infoIcon =
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>';
+5
View File
@@ -643,4 +643,9 @@
font-size: 2rem; font-size: 2rem;
} }
} }
@media (max-width: 639px) {
h1 {
font-size: 1.5em;
}
}
</style> </style>
+43 -12
View File
@@ -126,7 +126,7 @@
}); });
$effect(() => { $effect(() => {
if (!session || !famStore.initialized) return; if (!isParent || !session || !famStore.initialized) return;
const rewards = famStore.rewards; const rewards = famStore.rewards;
if (!mounted) { if (!mounted) {
mounted = true; mounted = true;
@@ -180,7 +180,7 @@
<div class="app-shell"> <div class="app-shell">
<Sidebar {famName} session={data.session} {isParent} {role} /> <Sidebar {famName} session={data.session} {isParent} {role} />
<TopNav <TopNav
{role} role={data.session ? role : ''}
seasons={famStore.seasons.filter((s) => s.active !== false)} seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''} announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
> >
@@ -191,7 +191,7 @@
{/if} {/if}
</button> </button>
</TopNav> </TopNav>
<main class="app-main"> <main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
<div class="page-wrap"> <div class="page-wrap">
<div class="page-content" class:locked>{@render children()}</div> <div class="page-content" class:locked>{@render children()}</div>
@@ -218,7 +218,12 @@
</div> </div>
</main> </main>
{#if claimToast} {#if claimToast}
<div class="claim-toast">{claimToast}</div> <a
class="claim-toast"
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
>
{claimToast} — view dashboard →
</a>
{/if} {/if}
<Footer sidebar /> <Footer sidebar />
</div> </div>
@@ -232,17 +237,33 @@
<style> <style>
.claim-toast { .claim-toast {
position: fixed; position: fixed;
top: 3.5rem; top: 52px;
right: 1.5rem; left: 220px;
background: #059669; right: 0;
color: white; background: #f59e0b;
padding: 0.6rem 1rem; color: #fff;
border-radius: 8px; padding: 0.5rem 1.5rem;
font-size: 0.85rem; font-size: 0.85rem;
z-index: 999; font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 95;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
text-decoration: none;
animation: fadein 0.2s; animation: fadein 0.2s;
} }
.claim-toast:hover {
background: #d97706;
}
@media (max-width: 1023px) {
.claim-toast {
left: 56px;
}
}
@media (max-width: 639px) {
.claim-toast {
left: 0;
padding: 0.5rem 1rem;
}
}
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@@ -283,6 +304,16 @@
margin-left: 56px; margin-left: 56px;
} }
} }
@media (max-width: 639px) {
.app-main {
padding-left: 1rem;
padding-right: 1rem;
}
/* Join pages center their own content — no shell padding needed */
.app-main.join-page {
padding: 0;
}
}
.page-wrap { .page-wrap {
position: relative; position: relative;
min-height: 70vh; min-height: 70vh;
+27 -4
View File
@@ -49,7 +49,6 @@
let tileChores = $derived( let tileChores = $derived(
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0 summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
); );
// ─── All-time header stats (server-computed) ─── // ─── All-time header stats (server-computed) ───
let allTimePts = $derived(Number(stats.allTimePts) || 0); let allTimePts = $derived(Number(stats.allTimePts) || 0);
let allTimeCash = $derived(Number(stats.allTimeCash) || 0); let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
@@ -58,6 +57,9 @@
// ─── Goals completed (rewards from bonus configs, pocket money excluded) ─── // ─── Goals completed (rewards from bonus configs, pocket money excluded) ───
// Derived from famStore so realtime updates (a goal met / revoked) flow in. // Derived from famStore so realtime updates (a goal met / revoked) flow in.
let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []); let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
// All-time goals achieved — every materialized bonus reward (prize / points /
// cash), including pocket money configs.
let tileGoals = $derived(rewards.filter((r: any) => r.bonusConfigId).length);
let bonusConfigs = $derived( let bonusConfigs = $derived(
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || [] famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
); );
@@ -326,6 +328,10 @@
<span class="tile-val">£{tileMoney.toFixed(2)}</span> <span class="tile-val">£{tileMoney.toFixed(2)}</span>
<span class="tile-lbl">cash this week</span> <span class="tile-lbl">cash this week</span>
</div> </div>
<div class="tile tile-goals">
<span class="tile-val">{tileGoals}</span>
<span class="tile-lbl">goals achieved</span>
</div>
</div> </div>
<CardGrid> <CardGrid>
@@ -364,7 +370,7 @@
{@const scVal = avgDailyPointsFor(s)} {@const scVal = avgDailyPointsFor(s)}
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0} {@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
{@const goals = goalsByMember.get(s.memberId)} {@const goals = goalsByMember.get(s.memberId)}
<div class="column col-member"> <div class="column col-member" style="--mc: {s.memberColor}">
<div class="member-head"> <div class="member-head">
<span class="dot" style="background:{s.memberColor}"></span> <span class="dot" style="background:{s.memberColor}"></span>
<span class="member-name">{s.memberName}</span> <span class="member-name">{s.memberName}</span>
@@ -527,7 +533,8 @@
/* ── Tiles (matching child dashboard) ── */ /* ── Tiles (matching child dashboard) ── */
.tiles { .tiles {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); /* members 1 unit wide; goals / points / cash 2 units each (1+2+2+2 on one row) */
grid-template-columns: 1fr 2fr 2fr 2fr;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -535,6 +542,9 @@
.tiles { .tiles {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.vh-num {
font-size: 1.5em;
}
} }
.tile { .tile {
border-radius: 14px; border-radius: 14px;
@@ -557,6 +567,9 @@
.tile-chores { .tile-chores {
background: linear-gradient(135deg, #0284c7, #38bdf8); background: linear-gradient(135deg, #0284c7, #38bdf8);
} }
.tile-goals {
background: linear-gradient(135deg, #f59e0b, #f97316);
}
.tile-val { .tile-val {
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 800; font-weight: 800;
@@ -584,7 +597,8 @@
} }
.col-member { .col-member {
border-top-color: #6366f1; border-top-color: #6366f1;
background: #eef2ff; /* Translucent wash of the member's own colour (set via --mc inline) */
background: color-mix(in srgb, var(--mc, #6366f1) 12%, transparent);
} }
.member-head { .member-head {
@@ -705,4 +719,13 @@
text-align: center; text-align: center;
padding: 1.5rem; padding: 1.5rem;
} }
/* Mobile: member columns stack full width (same convention as the chores
kanban) instead of the 450px horizontal scroll. Declared last so it wins
over the base .kanban rule. */
@media (max-width: 639px) {
.kanban {
grid-template-columns: 1fr;
min-width: 0;
}
}
</style> </style>
+103 -22
View File
@@ -144,10 +144,19 @@
} }
} }
// Same "Bonus name – prize" split as the ledger, so prize rewards don't
// duplicate the full label in both columns of a claim row.
function rewardLabelParts(r: any): { name: string; prize: string } {
const label = r.label || '';
const idx = label.indexOf(' – ');
if (idx === -1) return { name: label, prize: label };
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
}
function rewardLabel(r: any): string { function rewardLabel(r: any): string {
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`; if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`; if (r.rewardType === 'points') return `${r.value} pts`;
return r.label; return rewardLabelParts(r).prize;
} }
function manualConfigs() { function manualConfigs() {
@@ -605,8 +614,14 @@
const existingReward = rewards.find( const existingReward = rewards.find(
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId (r) => r.bonusConfigId === cfg.id && r.memberId === memberId
); );
// An earned reward only keeps lighting up the card while it's fresh
// (earned in the viewed week) or still outstanding (not yet claimed).
// Once claimed and the week rolls over, "Reward earned" disappears.
const rDate = (existingReward?.date || '').slice(0, 10);
const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd;
const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed';
const achieved = existingReward const achieved = existingReward
? true ? rewardInWeek || rewardOutstanding
: isPercent : isPercent
? totalPotential > 0 && ratio * 100 >= configured ? totalPotential > 0 && ratio * 100 >= configured
: target > 0 && current >= target; : target > 0 && current >= target;
@@ -729,6 +744,12 @@
function headDay(): number { function headDay(): number {
return headDate().getDate(); return headDate().getDate();
} }
function headDayOrdinal(): string {
const n = headDay();
if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
const s = ['th', 'st', 'nd', 'rd'];
return `${n}${s[n % 10] || s[0]}`;
}
function headWeekday(): string { function headWeekday(): string {
return headDate().toLocaleDateString(undefined, { weekday: 'long' }); return headDate().toLocaleDateString(undefined, { weekday: 'long' });
} }
@@ -914,7 +935,6 @@
</div> </div>
<div class="gm-bottom"> <div class="gm-bottom">
<span class="gm-reward">{g.rewardText}</span> <span class="gm-reward">{g.rewardText}</span>
<span class="gm-date">{formatDDMMYY(g.date)}</span>
</div> </div>
</div> </div>
{/each} {/each}
@@ -1047,7 +1067,7 @@
{/if} {/if}
</Card> </Card>
<Card cols={1} title="Claims" accent="#f59e0b"> <Card cols={1} title="Claims" accent="#f97316" class="claims-card">
{#if claimableRewards().length === 0} {#if claimableRewards().length === 0}
<div class="claims-empty"> <div class="claims-empty">
{@html checkCircleIcon}<span>No outstanding claims</span> {@html checkCircleIcon}<span>No outstanding claims</span>
@@ -1106,7 +1126,7 @@
<p class="section-label">Requested</p> <p class="section-label">Requested</p>
{#each requested as r} {#each requested as r}
<form <form
class="flex justify-between items-center" class="flex flex-wrap justify-between items-center"
method="POST" method="POST"
action="?/claim" action="?/claim"
use:enhance={() => { use:enhance={() => {
@@ -1118,7 +1138,7 @@
<span class="claim-icon" <span class="claim-icon"
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span >{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
> >
<span class="claim-label">{r.label}</span> <span class="claim-label">{rewardLabelParts(r).name}</span>
<span class="claim-value">{rewardLabel(r)}</span> <span class="claim-value">{rewardLabel(r)}</span>
</div> </div>
<Button type="submit" size="sm" variant="primary">Issue</Button> <Button type="submit" size="sm" variant="primary">Issue</Button>
@@ -1133,7 +1153,7 @@
<span class="claim-icon" <span class="claim-icon"
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span >{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
> >
<span class="claim-label">{r.label}</span> <span class="claim-label">{rewardLabelParts(r).name}</span>
<span class="claim-value"> <span class="claim-value">
{rewardLabel(r)} {rewardLabel(r)}
{#if paydayLocked(r)} {#if paydayLocked(r)}
@@ -1265,8 +1285,8 @@
{/if} {/if}
</div> </div>
<div class="hero-date"> <div class="hero-date">
<span class="hero-day">{headDay()}</span>
<span class="hero-weekday">{headWeekday()}</span> <span class="hero-weekday">{headWeekday()}</span>
<span class="hero-day">{headDayOrdinal()}</span>
<span class="hero-month">{headMonth()}</span> <span class="hero-month">{headMonth()}</span>
</div> </div>
</div> </div>
@@ -2224,11 +2244,15 @@
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
} }
.hero-days { .hero-days {
font-size: 0.85rem; font-size: 1rem;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
/* Own full-width line, centered under the hero content */
flex: 1 1 100%;
text-align: center;
margin-top: 0.25rem;
} }
.hero-days b { .hero-days b {
font-size: 1.4rem; font-size: 1.5rem;
margin-right: 0.15rem; margin-right: 0.15rem;
} }
.hero-nav { .hero-nav {
@@ -2483,20 +2507,25 @@
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
@media (max-width: 639px) {
.goal-mini-grid {
grid-template-columns: 1fr;
}
}
.goal-mini { .goal-mini {
background: linear-gradient(135deg, #d1d3fa, #ccfbf1); background: linear-gradient(135deg, #d1d3fa, #ccfbf1);
border: 1px solid #a7f3d0; border: 1px solid #a7f3d0;
border-radius: 12px; border-radius: 12px;
padding: 0.55rem 0.7rem; padding: 0.55rem 0.7rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.4rem; align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-width: 0; min-width: 0;
/* Own width as a query container, so tight cards drop the member name */
container-type: inline-size;
}
/* Card narrower than 250px: hide the member name so the row doesn't squish */
@container (max-width: 249px) {
.gm-member {
display: none;
}
} }
.gm-top { .gm-top {
display: flex; display: flex;
@@ -2526,18 +2555,14 @@
.gm-bottom { .gm-bottom {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
gap: 0.4rem; gap: 0.4rem;
white-space: nowrap;
} }
.gm-reward { .gm-reward {
font-weight: 800; font-weight: 800;
color: #047857; color: #047857;
font-size: 0.85rem; font-size: 0.85rem;
} }
.gm-date {
color: #9ca3af;
font-size: 0.72rem;
}
/* Goal celebration notice (child view) */ /* Goal celebration notice (child view) */
.celebrate-card.goal { .celebrate-card.goal {
@@ -2954,4 +2979,60 @@
font-size: 0.8rem; font-size: 0.8rem;
opacity: 0.85; opacity: 0.85;
} }
/* Mobile: declared last so it wins over the base .goal-mini (.kanban)
rules (same specificity, source order decides). */
@media (max-width: 639px) {
.goal-mini-grid {
grid-template-columns: 1fr;
}
.kanban {
grid-template-columns: 1fr;
}
/* Admin overview cards flow full width on phones — same convention as
the chores kanban (no horizontal scroll). */
.kanban-scroll {
overflow-x: visible;
}
.kanban-inner {
min-width: 0;
}
/* Keep the orange wash visible: restore body padding for this card
only (the shared Card removes it on mobile). */
:global(.card.claims-card .card-body) {
padding: 0.75rem;
}
/* Claim rows: icon/label/value stay on one line (label truncates
with ellipsis) and the Issue button becomes a full-width action
below — no squishing or overflow under 450px. */
.member-claims form.flex {
flex-wrap: wrap;
row-gap: 0.5rem;
}
.member-claims form.flex > div {
flex: 1 1 100%;
min-width: 0;
flex-wrap: nowrap;
align-items: center;
}
/* :global — the button element is rendered by the shared Button
component, so a scoped selector never matches it. */
.member-claims form.flex :global(button) {
flex: 1 1 100%;
width: 100%;
margin-left: 0;
}
.member-claims .claim-label {
white-space: nowrap;
}
.member-claims .claim-item {
flex-wrap: wrap;
}
}
/* Claims card — orange wash so pending requests are impossible to miss.
:global + !important deliberately override the shared Card's white/
mobile-transparent backgrounds. */
:global(.card.claims-card) {
background: linear-gradient(135deg, #ffedd5, #fdba74 70%, #fb923c) !important;
border-color: #f97316;
}
</style> </style>
@@ -197,7 +197,7 @@
const isShared = assignTarget === 'shared'; const isShared = assignTarget === 'shared';
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
memberId: isShared ? '' : assignModal.memberId, memberId: isShared ? '' : assignModal.memberId,
templateId: assignModal.t.id, templateId: assignModal.t?.id || '',
customName: assignName, customName: assignName,
description: assignDescription, description: assignDescription,
color: assignColor, color: assignColor,
@@ -242,6 +242,20 @@
showTodoModal = true; showTodoModal = true;
} }
function openCreateChore() {
assignModal = { t: null as any, memberId: members[0]?.id || '' };
assignStep = 1;
assignTarget = 'member';
assignName = '';
assignDescription = '';
assignColor = '';
assignIcon = '';
assignFreq = 'daily';
assignType = 'points';
assignValue = 10;
assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : [];
}
// Sort todos by completeBy (soonest first), then by startDate // Sort todos by completeBy (soonest first), then by startDate
function todoSort(a: AssignedChore, b: AssignedChore): number { function todoSort(a: AssignedChore, b: AssignedChore): number {
const da = a.completeBy || '9999-99-99'; const da = a.completeBy || '9999-99-99';
@@ -381,6 +395,7 @@
<div class="template-section"> <div class="template-section">
<div class="template-header"> <div class="template-header">
<h3>Templates — Drag to assign</h3> <h3>Templates — Drag to assign</h3>
<button class="add-inline" onclick={openCreateChore}>+ Create a chore</button>
</div> </div>
<div class="template-list"> <div class="template-list">
{#each templates as t} {#each templates as t}
@@ -527,11 +542,11 @@
class="accordion-wrapper accordion-chores" class="accordion-wrapper accordion-chores"
class:open={accordionState[m.id]?.chores ?? true} class:open={accordionState[m.id]?.chores ?? true}
> >
<button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}> <button class="accordion-head" onclick={() => toggleAccordion(m.id, 'chores')}>
<span class="accordion-title"> <span class="accordion-title">
<span class="accordion-icon">{@html choresIcon}</span> <span class="accordion-icon">{@html choresIcon}</span>
<span class="accordion-label">Chores</span> <span class="accordion-label">Chores</span>
</span> </span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span >{@html (accordionState[m.id]?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
@@ -619,8 +634,7 @@
<span class="accordion-label">Competitive Chores</span> <span class="accordion-label">Competitive Chores</span>
</span> </span>
<span class="accordion-chevron" <span class="accordion-chevron"
>{@html >{@html (accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
(accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
> >
</button> </button>
<span class="accordion-count" style="background:#6366f1" <span class="accordion-count" style="background:#6366f1"
@@ -720,12 +734,15 @@
<div <div
class="overlay" class="overlay"
onclick={(e) => { onclick={(e) => {
if (e.target === e.currentTarget) { assignModal = null; assignStep = 1; } if (e.target === e.currentTarget) {
assignModal = null;
assignStep = 1;
}
}} }}
role="presentation" role="presentation"
> >
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Assign: {assignModal.t.name}</h3> <h3>{assignModal.t ? `Assign: ${assignModal.t.name}` : 'Create a chore'}</h3>
<!-- Step tabs --> <!-- Step tabs -->
<div class="step-tabs" role="tablist"> <div class="step-tabs" role="tablist">
@@ -743,14 +760,22 @@
{#if assignStep === 1} {#if assignStep === 1}
<!-- Step 1: Who --> <!-- Step 1: Who -->
<label>Name <input bind:value={assignName} placeholder="Task name" /></label> <label>Name <input bind:value={assignName} placeholder="Task name" /></label>
<label>Description <input bind:value={assignDescription} placeholder="Optional description" /></label> <label
>Description <input
bind:value={assignDescription}
placeholder="Optional description"
/></label
>
<label> <label>
Assign to Assign to
<select bind:value={assignTarget} onchange={() => { <select
if (assignTarget === 'member' && !assignModal?.memberId && members.length) { bind:value={assignTarget}
assignModal = { ...assignModal!, memberId: members[0].id }; onchange={() => {
} if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
}}> assignModal = { ...assignModal!, memberId: members[0].id };
}
}}
>
<option value="member">A specific member</option> <option value="member">A specific member</option>
<option value="shared">Shared (all members)</option> <option value="shared">Shared (all members)</option>
</select> </select>
@@ -824,7 +849,13 @@
{#if assignStep > 1} {#if assignStep > 1}
<button type="button" onclick={() => assignStep--}>Back</button> <button type="button" onclick={() => assignStep--}>Back</button>
{:else} {:else}
<button type="button" onclick={() => { assignModal = null; assignStep = 1; }}>Cancel</button> <button
type="button"
onclick={() => {
assignModal = null;
assignStep = 1;
}}>Cancel</button
>
{/if} {/if}
{#if assignStep < 3} {#if assignStep < 3}
<button type="button" class="primary" onclick={() => assignStep++}>Next</button> <button type="button" class="primary" onclick={() => assignStep++}>Next</button>
@@ -1163,7 +1194,6 @@
width: 100%; width: 100%;
} }
} }
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */ /* ── Kanban (3 columns, scrolls via Card scrollX) ── */
.kanban { .kanban {
display: flex; display: flex;
@@ -1849,4 +1879,23 @@
opacity: 0.5; opacity: 0.5;
min-height: 200px; min-height: 200px;
} }
/* Mobile: declared last so it wins over the base .kanban/.column rules
(same specificity, source order decides). */
@media (max-width: 639px) {
.template-list {
display: none;
}
.kanban {
flex-direction: column;
overflow-x: visible;
}
.column {
flex: none;
width: 100%;
min-height: auto;
}
.column-placeholder {
display: none;
}
}
</style> </style>
@@ -5,14 +5,15 @@ export async function load(event) {
if (!event.locals.user) throw redirect(303, '/login'); if (!event.locals.user) throw redirect(303, '/login');
const famId = event.locals.user.famId; const famId = event.locals.user.famId;
const s = servicesFor(event); const s = servicesFor(event);
const [rewards, members, assigned, templates, completions] = await Promise.all([ const [rewards, members, assigned, templates, completions, fam] = await Promise.all([
s.crud.list('rewards', famId), s.crud.list('rewards', famId),
s.crud.list('members', famId), s.crud.list('members', famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', famId),
s.crud.list('chore-templates', famId), s.crud.list('chore-templates', famId),
s.crud.list('completions', famId) s.crud.list('completions', famId),
s.fam.get(famId)
]); ]);
return { rewards, members, assigned, templates, completions }; return { rewards, members, assigned, templates, completions, fam };
} }
export const actions = { export const actions = {
@@ -29,4 +30,4 @@ export const actions = {
return { error: e instanceof Error ? e.message : 'Failed to claim reward' }; return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
} }
} }
}; };
@@ -2,7 +2,8 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { famStore } from '$lib/stores/fam.svelte'; import { famStore } from '$lib/stores/fam.svelte';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { formatDDMMYY } from '$lib/format'; import { formatDDMMYY, formatShortDate } from '$lib/format';
import { resolveTz, weekStart as tzWeekStart, addDaysStr } from '@shared/timezone';
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types'; import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
let { data } = $props(); let { data } = $props();
@@ -24,6 +25,28 @@
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[]) famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
); );
// ── Week navigation (mirrors the member dashboard: payday-anchored weeks) ──
// Anchor comes from data.fam at SSR; famStore takes over after init so a
// payday/timezone change in settings flows through too.
let fam = $derived(
famStore.initialized ? (famStore.fam as any) : ((data as any).fam as any) || {}
);
let famPayday = $derived(fam?.payday != null ? Number(fam.payday) : 1);
let famTz = $derived(resolveTz(fam?.timezone || 'auto'));
let currentWeek = $derived.by(() => tzWeekStart(famPayday, famTz));
let weekStart = $state(tzWeekStart(famPayday, famTz));
let weekEnd = $derived(addDaysStr(weekStart, 6));
let isCurrentWeek = $derived(weekStart === currentWeek);
function inWeek(dateStr: string | undefined): boolean {
const d = (dateStr || '').slice(0, 10);
return !!d && d >= weekStart && d <= weekEnd;
}
function navigateWeek(amount: number) {
weekStart = addDaysStr(weekStart, amount * 7);
}
let activeTab = $state('rewards'); let activeTab = $state('rewards');
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m]))); let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
@@ -43,10 +66,22 @@
} }
// ── Rewards tab ── // ── Rewards tab ──
// Rewards store the label as "Bonus name – prize" (built in bonuses.ts:
// cash → "Reading Bonus 3 – £5.00", points → "Reading Bonus 3 – 50",
// items → "Reading Bonus 3 – fluffy toy"). Split it so the description
// column shows the bonus name and the amount column shows the prize;
// labels without a separator keep the full text in both columns.
function rewardLabelParts(r: any): { name: string; prize: string } {
const label = r.label || '';
const idx = label.indexOf(' – ');
if (idx === -1) return { name: label, prize: label };
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
}
function rewardAmount(r: any): string { function rewardAmount(r: any): string {
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`; if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
if (r.rewardType === 'points') return `${r.value} pts`; if (r.rewardType === 'points') return `${r.value} pts`;
return r.label; return rewardLabelParts(r).prize;
} }
function rewardStatus(r: any): string { function rewardStatus(r: any): string {
@@ -71,8 +106,11 @@
}) })
); );
let weekRewards = $derived(sortedRewards.filter((r: any) => inWeek(r.date)));
// Footer total matches the visible week, not all-time history.
let totalOutstanding = $derived( let totalOutstanding = $derived(
rewards weekRewards
.filter((r: any) => r.status !== 'claimed') .filter((r: any) => r.status !== 'claimed')
.filter((r: any) => r.rewardType === 'cash') .filter((r: any) => r.rewardType === 'cash')
.reduce((sum: number, r: any) => sum + Number(r.value), 0) .reduce((sum: number, r: any) => sum + Number(r.value), 0)
@@ -93,6 +131,9 @@
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || '')) [...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
); );
let weekChoreCompletions = $derived(sortedChoreCompletions.filter((c: any) => inWeek(c.date)));
let weekTodoCompletions = $derived(sortedTodoCompletions.filter((c: any) => inWeek(c.date)));
let toast = $state(''); let toast = $state('');
async function fire() { async function fire() {
@@ -125,6 +166,11 @@
active: activeTab, active: activeTab,
onchange: (v: string) => (activeTab = v) onchange: (v: string) => (activeTab = v)
}} }}
weeknav={{
current: isCurrentWeek ? 'This week' : formatShortDate(weekStart),
onPrev: () => navigateWeek(-1),
onNext: () => navigateWeek(1)
}}
/> />
{#if toast} {#if toast}
@@ -134,10 +180,11 @@
<CardGrid stackTablet> <CardGrid stackTablet>
{#if activeTab === 'rewards'} {#if activeTab === 'rewards'}
<Card cols={3}> <Card cols={3}>
{#if rewards.length === 0} {#if weekRewards.length === 0}
<p class="empty"> <p class="empty">
No rewards yet. Points earned will automatically create bonus rewards when thresholds are {isCurrentWeek
met. ? 'No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.'
: `No rewards for the week of ${formatShortDate(weekStart)}.`}
</p> </p>
{:else} {:else}
<table> <table>
@@ -152,15 +199,19 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sortedRewards as r} {#each weekRewards as r}
{@const outstanding = isOutstanding(r)} {@const outstanding = isOutstanding(r)}
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}> <tr
class:claimed={!outstanding && !isRequested(r)}
class:requested={isRequested(r)}
style="--mc:{memberColor(r.memberId)}"
>
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td> <td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
<td> <td>
<span class="dot" style="background:{memberColor(r.memberId)}"></span> <span class="dot" style="background:{memberColor(r.memberId)}"></span>
{memberName(r.memberId)} {memberName(r.memberId)}
</td> </td>
<td>{r.label}</td> <td>{rewardLabelParts(r).name}</td>
<td class="num">{rewardAmount(r)}</td> <td class="num">{rewardAmount(r)}</td>
<td> <td>
<span <span
@@ -208,8 +259,12 @@
</Card> </Card>
{:else if activeTab === 'chores'} {:else if activeTab === 'chores'}
<Card cols={3} scrollX title="Chore completions"> <Card cols={3} scrollX title="Chore completions">
{#if sortedChoreCompletions.length === 0} {#if weekChoreCompletions.length === 0}
<p class="empty">No chore completions yet.</p> <p class="empty">
{isCurrentWeek
? 'No chore completions yet.'
: `No chore completions for the week of ${formatShortDate(weekStart)}.`}
</p>
{:else} {:else}
<table> <table>
<thead> <thead>
@@ -221,9 +276,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sortedChoreCompletions as c} {#each weekChoreCompletions as c}
{@const a = assignedMap.get(c.assignedChoreId)} {@const a = assignedMap.get(c.assignedChoreId)}
<tr> <tr style="--mc:{memberColor(c.memberId)}">
<td class="date">{formatDDMMYY(c.date)}</td> <td class="date">{formatDDMMYY(c.date)}</td>
<td> <td>
<span class="dot" style="background:{memberColor(c.memberId)}"></span> <span class="dot" style="background:{memberColor(c.memberId)}"></span>
@@ -242,8 +297,12 @@
</Card> </Card>
{:else} {:else}
<Card cols={3} scrollX title="Todo completions"> <Card cols={3} scrollX title="Todo completions">
{#if sortedTodoCompletions.length === 0} {#if weekTodoCompletions.length === 0}
<p class="empty">No todos completed yet.</p> <p class="empty">
{isCurrentWeek
? 'No todos completed yet.'
: `No todos completed for the week of ${formatShortDate(weekStart)}.`}
</p>
{:else} {:else}
<table> <table>
<thead> <thead>
@@ -255,9 +314,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each sortedTodoCompletions as c} {#each weekTodoCompletions as c}
{@const a = assignedMap.get(c.assignedChoreId)} {@const a = assignedMap.get(c.assignedChoreId)}
<tr> <tr style="--mc:{memberColor(c.memberId)}">
<td class="date">{formatDDMMYY(c.date)}</td> <td class="date">{formatDDMMYY(c.date)}</td>
<td> <td>
<span class="dot" style="background:{memberColor(c.memberId)}"></span> <span class="dot" style="background:{memberColor(c.memberId)}"></span>
@@ -356,4 +415,20 @@
border-top: 2px solid #d1d5db; border-top: 2px solid #d1d5db;
padding-top: 0.6rem; padding-top: 0.6rem;
} }
@media (max-width: 639px) {
/* Hide Member (2nd) and Status (5th, rewards only) columns */
th:nth-child(2),
td:nth-child(2) {
display: none;
}
th:nth-child(5),
td:nth-child(5) {
display: none;
}
/* Member colour as the entry background instead of the member column */
tbody tr,
tbody tr.requested {
background: color-mix(in srgb, var(--mc, #6366f1) 10%, transparent);
}
}
</style> </style>
@@ -1161,4 +1161,7 @@
.resume-hint { .resume-hint {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@media (max-width: 639px) {
.code-display { font-size: 1.5em; }
}
</style> </style>
+4 -2
View File
@@ -14,7 +14,9 @@
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6"> <main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm"> <section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"> <div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"
>
<span class="home-badge">{@html homeIcon}</span> <span class="home-badge">{@html homeIcon}</span>
</div> </div>
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1> <h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
@@ -51,7 +53,7 @@
maxlength="6" maxlength="6"
placeholder="6-digit code" placeholder="6-digit code"
autocomplete="one-time-code" autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/> />
{#if form?.error} {#if form?.error}
<p class="text-sm font-medium text-rose-600">{form.error}</p> <p class="text-sm font-medium text-rose-600">{form.error}</p>
@@ -58,7 +58,7 @@
maxlength="6" maxlength="6"
placeholder="6-digit code" placeholder="6-digit code"
autocomplete="one-time-code" autocomplete="one-time-code"
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
/> />
{#if isParent} {#if isParent}
<input <input