Compare commits

..

8 Commits

Author SHA1 Message Date
JCEEE 486f239fb1 add transparency for themes 2026-09-10 19:07:57 +01:00
JCEEE a5e3b80ff6 1.10.4 2026-09-10 18:54:58 +01:00
JCEEE 41033914b4 add platform demo fam 2026-09-10 18:54:38 +01:00
JCEEE 4730ab3eb9 add showboat fam and rotating data 2026-09-10 15:03:27 +01:00
JCEEE a270d965fe add theme patterns 2026-09-10 13:23:55 +01:00
JCEEE 27492b1558 send email on claim requests 2026-09-10 11:31:40 +01:00
JCEEE 897e203dab 1.10.3 2026-09-10 11:27:45 +01:00
JCEEE f802113cd1 loads of mobile ui layout upgrades 2026-09-10 11:27:27 +01:00
36 changed files with 2492 additions and 185 deletions
+4 -1
View File
@@ -55,7 +55,10 @@ export const handle: Handle = async ({ event, resolve }) => {
username: handleOf(record.username || ''), username: handleOf(record.username || ''),
role: record.role || 'parent', role: record.role || 'parent',
famId: record.famId, famId: record.famId,
color: record.color || '' color: record.color || '',
pattern: record.pattern || '',
themeSize: record.themeSize || '',
themeOpacity: record.themeOpacity || ''
} satisfies SessionUser; } satisfies SessionUser;
event.locals.pbToken = freshToken; event.locals.pbToken = freshToken;
+30 -7
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
@@ -44,6 +52,14 @@
.card.has-accent { .card.has-accent {
border-top: 3px solid var(--card-accent, #6366f1); border-top: 3px solid var(--card-accent, #6366f1);
} }
/* Background pattern active (has-pattern on the layout root): cards go
translucent so the pattern shows through. Desktop/tablet only — mobile
cards are already fully transparent. */
@media (min-width: 640px) {
:global(.has-pattern) .card {
background: #ffffffd9;
}
}
/* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */ /* Tablet (2-col grid): anything spanning 3+ collapses to a full row (span 2) */
@media (min-width: 640px) and (max-width: 1023px) { @media (min-width: 640px) and (max-width: 1023px) {
.card[data-cols='3'], .card[data-cols='3'],
@@ -53,12 +69,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 +86,17 @@
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* 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> </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>
@@ -0,0 +1,184 @@
<script lang="ts">
import '../theme-patterns.css';
import {
themeShades,
PATTERN_COUNT,
patternId,
THEME_SIZE_MIN,
THEME_SIZE_MAX,
THEME_SIZE_DEFAULT,
THEME_OPACITY_MIN,
THEME_OPACITY_MAX,
THEME_OPACITY_STEP,
THEME_OPACITY_DEFAULT
} from '$lib/theme';
let {
value = '',
color = '#6366f1',
size = THEME_SIZE_DEFAULT,
opacity = THEME_OPACITY_DEFAULT,
onchange,
onsize,
onopacity
}: {
value?: string;
color?: string;
size?: number;
opacity?: number;
onchange: (v: string) => void;
onsize?: (v: number) => void;
onopacity?: (v: number) => void;
} = $props();
let shades = $derived(themeShades(color));
// Inline vars beat the pattern class defaults; --s shrinks the pattern so
// thumbnails show a sample of the tiling.
let vars = $derived(
`--c1:${shades.c1};--c2:${shades.c2};--c3:${shades.c3};--c4:${shades.c4};--s:26px`
);
// Local slider state — bound directly to the inputs (no controlled-value
// re-render mid-drag), then emitted on input for the live preview.
let sizeVal = $state(size);
let opacityVal = $state(opacity);
// Keep local state in sync if the props ever change (e.g. after a save).
$effect(() => {
sizeVal = size;
});
$effect(() => {
opacityVal = opacity;
});
</script>
<div class="pattern-grid">
<button
type="button"
class="pattern-tile none"
class:selected={value === ''}
title="No background pattern"
onclick={() => onchange('')}
>
<span class="none-dot" style="background:{color}"></span>
</button>
{#each Array(PATTERN_COUNT) as _, i}
{@const id = patternId(i + 1)}
<button
type="button"
class="pattern-tile pattern-{id}"
class:selected={value === id}
title={`Pattern ${i + 1}`}
style={vars}
onclick={() => onchange(id)}
></button>
{/each}
</div>
{#if value !== ''}
{#if onsize}
<div class="slider-row">
<div class="slider-head">
<label for="theme-size">Pattern size</label>
<span class="slider-val">{sizeVal}</span>
</div>
<input
id="theme-size"
type="range"
min={THEME_SIZE_MIN}
max={THEME_SIZE_MAX}
step="0.5"
bind:value={sizeVal}
oninput={() => onsize?.(sizeVal)}
/>
<span class="slider-hint">scales with screen width</span>
</div>
{/if}
{#if onopacity}
<div class="slider-row">
<div class="slider-head">
<label for="theme-opacity">Opacity</label>
<span class="slider-val">{Math.round(opacityVal * 100)}%</span>
</div>
<input
id="theme-opacity"
type="range"
min={THEME_OPACITY_MIN}
max={THEME_OPACITY_MAX}
step={THEME_OPACITY_STEP}
bind:value={opacityVal}
oninput={() => onopacity?.(opacityVal)}
/>
<span class="slider-hint">max = current look</span>
</div>
{/if}
{/if}
<style>
.pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
gap: 0.5rem;
margin-bottom: 0.5rem;
/* Static grid — currently 19 tiles fit without scrolling. Padding keeps
the selected tile's outline from clipping at the top-left corner. */
padding: 0.4rem;
}
.pattern-tile {
aspect-ratio: 1;
border-radius: 8px;
border: 1px solid #e5e7eb;
cursor: pointer;
padding: 0;
/* No background here — the imported .pattern-pN classes supply it.
A local background (0,2,0) would beat .pattern-pN (0,1,0) and hide
every preview. */
transition: outline 0.12s ease;
}
.pattern-tile:hover {
border-color: #9ca3af;
}
.pattern-tile.selected {
outline: 3px solid #111827;
outline-offset: 2px;
}
.pattern-tile.none {
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.none-dot {
width: 14px;
height: 14px;
border-radius: 50%;
}
.slider-row {
margin-top: 0.75rem;
font-size: 0.8rem;
color: #374151;
}
.slider-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.3rem;
}
.slider-head label {
font-weight: 600;
}
.slider-row input[type='range'] {
width: 100%;
display: block;
accent-color: #6366f1;
}
.slider-val {
font-weight: 700;
color: #4338ca;
}
.slider-hint {
color: #9ca3af;
font-size: 0.72rem;
display: block;
margin-top: 0.15rem;
}
</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; }
+25 -3
View File
@@ -11,8 +11,14 @@
prefsIcon prefsIcon
} from './icons'; } from './icons';
import FamDone from './FamDone.svelte'; import FamDone from './FamDone.svelte';
import { famStore } from '$lib/stores/fam.svelte';
let { famName = '', session = null, isParent = false, role = 'child' } = $props(); // 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', isDemo = false } = $props();
let famSlug = $derived(page.data.famSlug ?? page.params.fam); let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || ''); let memberName = $derived(session?.memberName || page.params.username || '');
@@ -37,10 +43,10 @@
); );
let footerItems = $derived([ let footerItems = $derived([
...(memberName ...(memberName && !isDemo
? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }] ? [{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon }]
: []), : []),
...(isParent && memberName ...(isParent && memberName && !isDemo
? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }]
: []), : []),
{ {
@@ -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>';
+1
View File
@@ -13,3 +13,4 @@ export { default as AuthShell } from './AuthShell.svelte';
export { default as NoticeDialog } from './NoticeDialog.svelte'; export { default as NoticeDialog } from './NoticeDialog.svelte';
export { default as PricingPlans } from './PricingPlans.svelte'; export { default as PricingPlans } from './PricingPlans.svelte';
export { default as CheckboxGrid } from './CheckboxGrid.svelte'; export { default as CheckboxGrid } from './CheckboxGrid.svelte';
export { default as PatternPicker } from './PatternPicker.svelte';
+51
View File
@@ -1,14 +1,31 @@
import { Resend } from 'resend'; import { Resend } from 'resend';
import { RESEND_API } from '$app/env/private'; import { RESEND_API } from '$app/env/private';
import { getAccessEmailTemplate } from './email-templates'; import { getAccessEmailTemplate } from './email-templates';
import { getPlatformFlags } from '$lib/server/platform';
const resend = new Resend(String(RESEND_API)); const resend = new Resend(String(RESEND_API));
let demoCache: { value: boolean; at: number } | null = null;
const DEMO_TTL_MS = 10_000;
async function isDemoMode(): Promise<boolean> {
if (demoCache && Date.now() - demoCache.at < DEMO_TTL_MS) return demoCache.value;
try {
const flags = await getPlatformFlags();
demoCache = { value: flags.demo === true, at: Date.now() };
return flags.demo === true;
} catch {
demoCache = { value: false, at: Date.now() };
return false;
}
}
export async function sendPasswordResetEmail(opts: { export async function sendPasswordResetEmail(opts: {
to: string; to: string;
resetLink: string; resetLink: string;
famName: string; famName: string;
}) { }) {
if (await isDemoMode()) return;
const { to, resetLink, famName } = opts; const { to, resetLink, famName } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -30,6 +47,7 @@ export async function sendParentInviteEmail(opts: {
famName: string; famName: string;
otp: string; otp: string;
}) { }) {
if (await isDemoMode()) return;
const { to, inviteLink, famName, otp } = opts; const { to, inviteLink, famName, otp } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -52,6 +70,7 @@ export async function sendAccessUnlockedEmail(opts: {
codeValue: string; codeValue: string;
dashboardUrl: string; dashboardUrl: string;
}) { }) {
if (await isDemoMode()) return;
const { to, famName, codeName, codeValue, dashboardUrl } = opts; const { to, famName, codeName, codeValue, dashboardUrl } = opts;
const template = getAccessEmailTemplate(codeValue); const template = getAccessEmailTemplate(codeValue);
@@ -70,6 +89,7 @@ export async function sendWelcomeEmail(opts: {
famName: string; famName: string;
dashboardUrl: string; dashboardUrl: string;
}) { }) {
if (await isDemoMode()) return;
const { to, famName, dashboardUrl } = opts; const { to, famName, dashboardUrl } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -112,6 +132,7 @@ export async function sendMentionEmail(opts: {
message: string; message: string;
dashboardUrl: string; dashboardUrl: string;
}) { }) {
if (await isDemoMode()) return;
const { to, parentName, authorName, message, dashboardUrl } = opts; const { to, parentName, authorName, message, dashboardUrl } = opts;
await resend.emails.send({ await resend.emails.send({
@@ -127,3 +148,33 @@ export async function sendMentionEmail(opts: {
` `
}); });
} }
// A child asked to collect a reward — ping the parent(s) with the ask and a
// link straight to the Claims card on the admin dashboard.
export async function sendClaimRequestEmail(opts: {
to: string;
parentName: string;
childName: string;
rewardName: string;
rewardValue: string;
dashboardUrl: string;
}) {
if (await isDemoMode()) return;
const { to, parentName, childName, rewardName, rewardValue, dashboardUrl } = opts;
await resend.emails.send({
from: 'no-reply@walthamstow.xyz',
to,
subject: `${childName} asked to claim ${rewardName}`,
html: `
<p>Hi ${escHtml(parentName)},</p>
<p><strong>${escHtml(childName)}</strong> asked to collect a reward:</p>
<p>
<strong>${escHtml(rewardName)}</strong>
${rewardValue ? `— ${escHtml(rewardValue)}` : ''}
</p>
<p><a href="${dashboardUrl}">Open the Claims card on your dashboard</a> to review and issue it.</p>
<p>Talk soon,<br />The FamDone team</p>
`
});
}
+390 -2
View File
@@ -1,11 +1,13 @@
import { SCHEMA_PLAN } from '@shared/pb/schema'; import { SCHEMA_PLAN } from '@shared/pb/schema';
import { PB_ENDPOINT } from '$lib/server/pocketbase'; import { PB_ENDPOINT } from '$lib/server/pocketbase';
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; import { PB_EMAIL, PB_PASSWORD, MEMBER_SECRET } from '$app/env/private';
import { getPlatformFlags } from '$lib/server/platform';
let token: string | null = null; let token: string | null = null;
let tokenExpiry = 0;
async function auth(): Promise<string> { async function auth(): Promise<string> {
if (token) return token; if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, { const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -14,9 +16,16 @@ async function auth(): Promise<string> {
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`); if (!res.ok) throw new Error(`PB auth failed: ${JSON.stringify(data)}`);
token = data.token; token = data.token;
// PB JWTs expire in 1 hour; refresh 5 min early.
tokenExpiry = Date.now() + 55 * 60 * 1000;
return token!; return token!;
} }
async function isDemo(): Promise<boolean> {
const flags = await getPlatformFlags();
return flags.demo === true;
}
async function getCollection(name: string): Promise<any | null> { async function getCollection(name: string): Promise<any | null> {
const t = await auth(); const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, { const res = await fetch(`${PB_ENDPOINT}/api/collections?filter=name='${name}'`, {
@@ -146,6 +155,18 @@ async function ensureUsers(ids: Record<string, string>): Promise<void> {
usersCol.fields.push({ name: 'color', type: 'text', required: false }); usersCol.fields.push({ name: 'color', type: 'text', required: false });
changed = true; changed = true;
} }
if (!has('pattern')) {
usersCol.fields.push({ name: 'pattern', type: 'text', required: false });
changed = true;
}
if (!has('themeSize')) {
usersCol.fields.push({ name: 'themeSize', type: 'text', required: false });
changed = true;
}
if (!has('themeOpacity')) {
usersCol.fields.push({ name: 'themeOpacity', type: 'text', required: false });
changed = true;
}
if (!has('passwordResetToken')) { if (!has('passwordResetToken')) {
usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false }); usersCol.fields.push({ name: 'passwordResetToken', type: 'text', required: false });
changed = true; changed = true;
@@ -429,6 +450,10 @@ export async function migrate(): Promise<void> {
await ensureAccessCodes(); await ensureAccessCodes();
await ensurePlatform(); await ensurePlatform();
await ensureDefaultSeasons(); await ensureDefaultSeasons();
if (await isDemo()) {
await seedDemoFamily();
await seedDemoCompletions();
}
console.log('[migrate] Done'); console.log('[migrate] Done');
} }
@@ -762,4 +787,367 @@ async function seedGlobalTemplates(): Promise<void> {
console.log('[migrate] Seeded global templates.'); console.log('[migrate] Seeded global templates.');
} }
// ── Demo family seed ────────────────────────────────────────────────────────
// Creates a "Showboaters" family with one admin (demo) and three kids
// (Juno, Jackson, Jalipo). Idempotent — skips if the family already exists.
// The parent password is `demo1234` (PB enforces ≥8 chars). Children's
// derived passwords are handled by the normal member-otp flow.
const DEMO_FAM = {
name: 'Showboaters',
slug: 'showboaters',
parentName: 'Demo',
parentEmail: 'demo@famdone.xyz',
parentPassword: 'demo1234',
children: [
{ name: 'Juno', color: '#f472b6' },
{ name: 'Jackson', color: '#60a5fa' },
{ name: 'Jalipo', color: '#34d399' }
]
};
async function seedDemoFamily(): Promise<void> {
const t = await auth();
// Check if the demo family already exists
const famRes = await fetch(
`${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
const famData = await famRes.json();
if (famData?.items?.length) return; // already seeded
console.log('[migrate] Seeding demo family "Showboaters"...');
// 1. Create the family
const createdFam = await fetch(`${PB_ENDPOINT}/api/collections/fams/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
name: DEMO_FAM.name,
slug: DEMO_FAM.slug,
timezone: 'auto',
paymentMode: 'none',
active: false
})
});
const fam = await createdFam.json();
if (!createdFam.ok) throw new Error(`Seed demo fam failed: ${JSON.stringify(fam)}`);
const famId = fam.id;
// 2. Create the parent (admin) user
const parentUsername = `${DEMO_FAM.slug}:${DEMO_FAM.parentName.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const createdParent = await fetch(`${PB_ENDPOINT}/api/collections/users/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
username: parentUsername,
name: DEMO_FAM.parentName,
email: DEMO_FAM.parentEmail,
emailVisibility: false,
password: DEMO_FAM.parentPassword,
passwordConfirm: DEMO_FAM.parentPassword,
famId,
role: 'parent'
})
});
if (!createdParent.ok) {
const pd = await createdParent.json();
throw new Error(`Seed demo parent failed: ${JSON.stringify(pd)}`);
}
// 3. Create settings for the family
await fetch(`${PB_ENDPOINT}/api/collections/settings/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ famId })
});
// 4. Apply access code (dev123) to unlock the family
const codeRes = await fetch(
`${PB_ENDPOINT}/api/collections/accesscodes/records?filter=value='dev123'&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
const codeData = await codeRes.json();
const accessCode = codeData?.items?.[0];
if (accessCode) {
await fetch(`${PB_ENDPOINT}/api/collections/fams/records/${famId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
paymentMode: 'code',
active: true,
accessCodeId: accessCode.id,
accessCodeEnteredAt: new Date().toISOString()
})
});
}
// 5. Create child users (via the same derive-password path as member-otp)
for (const child of DEMO_FAM.children) {
const childHandle = child.name.toLowerCase().replace(/[^a-z0-9]/g, '');
const childUsername = `${DEMO_FAM.slug}:${childHandle}`;
const childPassword = `${String(MEMBER_SECRET)}${DEMO_FAM.slug}${childHandle}`;
await fetch(`${PB_ENDPOINT}/api/collections/users/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
username: childUsername,
name: child.name,
color: child.color,
password: childPassword,
passwordConfirm: childPassword,
famId,
role: 'child'
})
}).catch((e) => {
console.warn(`[migrate] Failed to create demo child ${child.name}:`, e);
});
// Auto-create pocket-money droplet
await fetch(`${PB_ENDPOINT}/api/collections/bonus_configs/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
famId,
name: 'Pocket Money',
target: 'individual',
type: 'threshold',
thresholdType: 'percent',
occurrence: 'recurring',
rewardType: 'cash',
rewardValue: '',
criteriaValue: 50,
period: 'weekly',
status: 'active',
isPocketMoney: true
})
}).catch(() => {});
}
console.log('[migrate] ✓ Demo family "Showboaters" seeded');
console.log(`[migrate] Login: ${DEMO_FAM.parentEmail} / ${DEMO_FAM.parentPassword}`);
}
// ── Demo rotating data ──────────────────────────────────────────────────────
// On every boot, if the demo family exists and doesn't already have today's
// completions, wipe and regenerate 21 days of deterministic completion data.
// This ensures the demo always shows a realistic 3-week history relative to
// the current date, without manual re-seeding.
// Simple seeded PRNG (mulberry32) so the same data is produced every time.
function demoRng(seed: number): () => number {
let s = seed | 0;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function dateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
// Each kid's "completion personality" — Juno is reliable, Jackson solid, Jalipo spotty.
// Maps assigned chore customName → weekday (0=Sun) → probability of completion.
const COMPLETION_PROFILES: Record<string, Record<number, Record<string, number>>> = {
'Juno': {
0: { default: 0.85 }, // Sun
1: { default: 0.95 }, // Mon
2: { default: 0.9 },
3: { default: 0.95 },
4: { default: 0.9 },
5: { default: 0.85 }, // Fri
6: { default: 0.8 } // Sat
},
'Jackson': {
0: { default: 0.7 },
1: { default: 0.9 },
2: { default: 0.85 },
3: { default: 0.8 },
4: { default: 0.75 },
5: { default: 0.7 },
6: { default: 0.6 }
},
'Jalipo': {
0: { default: 0.4 },
1: { default: 0.75 },
2: { default: 0.65 },
3: { default: 0.7 },
4: { default: 0.6 },
5: { default: 0.5 },
6: { default: 0.35 }
}
};
async function seedDemoCompletions(): Promise<void> {
const t = await auth();
// Find the demo family
const famRes = await fetch(
`${PB_ENDPOINT}/api/collections/fams/records?filter=slug='${DEMO_FAM.slug}'&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
const famData = await famRes.json();
const fam = famData?.items?.[0];
if (!fam) return; // demo family not seeded yet
const famId = fam.id;
// Check if today's completions already exist (idempotent)
const today = dateStr(new Date());
const todayComps = await fetch(
`${PB_ENDPOINT}/api/collections/completions/records?filter=${encodeURIComponent(`famId='${famId}' && date='${today}'`)}&perPage=1`,
{ headers: { Authorization: `Bearer ${t}` } }
);
if ((await todayComps.json())?.items?.length) {
console.log('[migrate] Demo data already rotated for today — skipping.');
return;
}
console.log('[migrate] Rotating demo data for Showboaters...');
// Fetch members, assigned chores
const [membersRes, assignedRes] = await Promise.all([
fetch(`${PB_ENDPOINT}/api/collections/users/records?filter=${encodeURIComponent(`famId='${famId}' && role='child'`)}&perPage=10`, {
headers: { Authorization: `Bearer ${t}` }
}),
fetch(`${PB_ENDPOINT}/api/collections/assigned_chores/records?filter=famId='${famId}'&perPage=100`, {
headers: { Authorization: `Bearer ${t}` }
})
]);
const members: any[] = (await membersRes.json())?.items || [];
const assigned: any[] = (await assignedRes.json())?.items || [];
if (!members.length || !assigned.length) return;
// Delete existing completions and weekly_history for the demo family
const [oldCompsRes, oldHistoryRes] = await Promise.all([
fetch(`${PB_ENDPOINT}/api/collections/completions/records?filter=famId='${famId}'&perPage=500`, {
headers: { Authorization: `Bearer ${t}` }
}),
fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records?filter=famId='${famId}'&perPage=500`, {
headers: { Authorization: `Bearer ${t}` }
})
]);
const oldComps: any[] = (await oldCompsRes.json())?.items || [];
const oldHistory: any[] = (await oldHistoryRes.json())?.items || [];
for (const r of oldComps) {
await fetch(`${PB_ENDPOINT}/api/collections/completions/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${t}` }
}).catch(() => {});
}
for (const r of oldHistory) {
await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${t}` }
}).catch(() => {});
}
// Generate 21 days of completions (today - 20 → today)
const todayDate = new Date();
todayDate.setUTCHours(12, 0, 0, 0); // anchor at noon UTC
const startDate = addDays(todayDate, -20);
// Track per-member-per-week totals for weekly_history
type WeekKey = string; // "YYYY-MM-DD" (Monday)
const weekTotals: Record<string, Record<string, { points: number; money: number; count: number }>> = {};
for (const member of members) {
const profile = COMPLETION_PROFILES[member.name] || COMPLETION_PROFILES['Jalipo'];
for (let d = 0; d < 21; d++) {
const day = addDays(startDate, d);
const ds = dateStr(day);
const dow = day.getUTCDay(); // 0=Sun
// Compute week start (Monday) for weekly_history tracking
const weekMonday = new Date(day);
weekMonday.setUTCDate(weekMonday.getUTCDate() - ((weekMonday.getUTCDay() + 6) % 7));
const ws = dateStr(weekMonday);
if (!weekTotals[member.id]) weekTotals[member.id] = {};
if (!weekTotals[member.id][ws]) weekTotals[member.id][ws] = { points: 0, money: 0, count: 0 };
const dayProfile = profile[dow] || profile[0];
// Find this member's assigned chores
const memberChores = assigned.filter(
(a: any) => a.memberId === member.id || (!a.memberId && a.shared)
);
for (const chore of memberChores) {
// Weekly chores: only complete on Monday (dow=1)
if (chore.frequency === 'weekly' && dow !== 1) continue;
const prob = dayProfile[chore.customName] || dayProfile.default;
const rng = demoRng(
famId.length * 1000 +
member.id.length * 100 +
d * 10 +
(chore.id?.charCodeAt(0) || 0)
);
if (rng() > prob) continue; // missed this day
// Create completion record
await fetch(`${PB_ENDPOINT}/api/collections/completions/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
famId,
memberId: member.id,
assignedChoreId: chore.id,
date: ds
})
}).catch(() => {});
// Track totals
const val = Number(chore.value) || 0;
if (chore.type === 'points') {
weekTotals[member.id][ws].points += val;
} else if (chore.type === 'money') {
weekTotals[member.id][ws].money += val;
}
weekTotals[member.id][ws].count++;
}
}
}
// Create weekly_history records for each week that has data
for (const memberId of Object.keys(weekTotals)) {
for (const ws of Object.keys(weekTotals[memberId])) {
const totals = weekTotals[memberId][ws];
if (totals.count === 0) continue;
await fetch(`${PB_ENDPOINT}/api/collections/weekly_history/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({
famId,
memberId,
weekStart: ws,
pointsEarned: totals.points,
moneyEarned: totals.money,
choresCompleted: totals.count,
bonusEarned: 0
})
}).catch(() => {});
}
}
console.log('[migrate] ✓ Demo completions rotated (21 days)');
}
export { GLOBAL_TEMPLATE_ICONS }; export { GLOBAL_TEMPLATE_ICONS };
export { seedDemoCompletions };
+21 -2
View File
@@ -69,7 +69,15 @@ export async function patchFam(pb: any, famId: string, body: Record<string, unkn
export async function getProfile(pb: any, famId: string, userId: string) { export async function getProfile(pb: any, famId: string, userId: string) {
const rec = await pb.collection('users').getOne(userId); const rec = await pb.collection('users').getOne(userId);
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' }; return {
id: rec.id,
name: rec.name || '',
color: rec.color || '#6366f1',
pattern: rec.pattern || '',
themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '',
email: rec.email || ''
};
} }
export async function updateProfile( export async function updateProfile(
@@ -81,9 +89,20 @@ export async function updateProfile(
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
if (data.name) patch.name = data.name; if (data.name) patch.name = data.name;
if (data.color) patch.color = data.color; if (data.color) patch.color = data.color;
if (data.pattern !== undefined) patch.pattern = data.pattern;
if (data.themeSize !== undefined) patch.themeSize = data.themeSize;
if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity;
if (data.email !== undefined) patch.email = data.email; if (data.email !== undefined) patch.email = data.email;
const rec = await pb.collection('users').update(userId, patch); const rec = await pb.collection('users').update(userId, patch);
return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', email: rec.email || '' }; return {
id: rec.id,
name: rec.name || '',
color: rec.color || '#6366f1',
pattern: rec.pattern || '',
themeSize: rec.themeSize || '',
themeOpacity: rec.themeOpacity || '',
email: rec.email || ''
};
} }
export async function weeklySummary(pb: any, famId: string) { export async function weeklySummary(pb: any, famId: string) {
+3
View File
@@ -5,4 +5,7 @@ export interface SessionUser {
role: 'parent' | 'child'; role: 'parent' | 'child';
famId: string; famId: string;
color?: string; color?: string;
pattern?: string;
themeSize?: string;
themeOpacity?: string;
} }
+14
View File
@@ -0,0 +1,14 @@
// Live theme draft — the PatternPicker writes here the moment a tile (or a
// colour swatch) is clicked, so the fam layout can preview the background
// instantly. `null` means "no draft yet — use the saved value from session".
export const themeDraft = $state<{
pattern: string | null;
color: string | null;
size: number | null;
opacity: number | null;
}>({
pattern: null,
color: null,
size: null,
opacity: null
});
+991
View File
@@ -0,0 +1,991 @@
/* Generated from css-patterns.css (see scripts/gen-theme-patterns.mjs).
57 unique patterns (3 duplicates removed).
Colors are overridden per-user via --c1..--c4 set inline on the element
(hsl shades of the chosen swatch). */
html .app-shell.pattern-p1,
.pattern-p1 {
--s: 84px; /* control the size*/
--c1: #f2f2f2;
--c2: #cdcbcc;
--c3: #999999;
--_g: 0 120deg,#0000 0;
background:
conic-gradient( at calc(250%/3) calc(100%/3),var(--c3) var(--_g)),
conic-gradient(from -120deg at calc( 50%/3) calc(100%/3),var(--c2) var(--_g)),
conic-gradient(from 120deg at calc(100%/3) calc(250%/3),var(--c1) var(--_g)),
conic-gradient(from 120deg at calc(200%/3) calc(250%/3),var(--c1) var(--_g)),
conic-gradient(from -180deg at calc(100%/3) 50%,var(--c2) 60deg,var(--c1) var(--_g)),
conic-gradient(from 60deg at calc(200%/3) 50%,var(--c1) 60deg,var(--c3) var(--_g)),
conic-gradient(from -60deg at 50% calc(100%/3),var(--c1) 120deg,var(--c2) 0 240deg,var(--c3) 0);
background-size: calc(var(--s)*sqrt(3)) var(--s);
}
html .app-shell.pattern-p2,
.pattern-p2 {
--s: 200px; /* control the size*/
--c1: #1d1d1d;
--c2: #4e4f51;
--c3: #3c3c3c;
background:
repeating-conic-gradient(from 30deg,#0000 0 120deg,var(--c3) 0 50%)
calc(var(--s)/2) calc(var(--s)*tan(30deg)/2),
repeating-conic-gradient(from 30deg,var(--c1) 0 60deg,var(--c2) 0 120deg,var(--c3) 0 50%);
background-size: var(--s) calc(var(--s)*tan(30deg));
}
html .app-shell.pattern-p3,
.pattern-p3 {
--s: 64px; /* control the size*/
--c1: #c02942;
--c2: #53777a;
--c3: #ecd078;
--c4: #d95b43;
background:
radial-gradient(var(--c1) 24%,#0000 25%),
radial-gradient(var(--c2) 30%,#0000 32%) calc(var(--s)/2) calc(var(--s)/2),
repeating-conic-gradient(from 30deg,var(--c3) 0 30deg,var(--c4) 0 25%);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p4,
.pattern-p4 {
--s: 100px; /* control the size*/
--c1: #fff0e5;
--c2: #025d8c;
--c3: #e1642a;
--_g: 50%,#0000 37%,var(--c1) 39% 70%,#0000 72%;
--_t: 50%,var(--c2) 40deg,var(--c3) 0 140deg,var(--c2) 0 180deg,#0000 0;
--_s: 47% 50% at;
background:
radial-gradient(var(--_s) -10% var(--_g)) 0 calc(var(--s)/2),
radial-gradient(var(--_s) -10% var(--_g)) calc(var(--s)/2) 0,
radial-gradient(var(--_s) 110% var(--_g)),
radial-gradient(var(--_s) 110% var(--_g)) calc(var(--s)/2) calc(var(--s)/2),
conic-gradient(from 0deg at 55% var(--_t)) calc(var(--s)/4) 0,
conic-gradient(from 180deg at 45% var(--_t)) calc(var(--s)/4) 0,
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p5,
.pattern-p5 {
--s: 150px; /* control the size*/
--c1: #f7d2a1;
--c2: #05057e;
--_g:
var(--c1) 0% 5% ,var(--c2) 6% 15%,var(--c1) 16% 25%,var(--c2) 26% 35%,var(--c1) 36% 45%,
var(--c2) 46% 55%,var(--c1) 56% 65%,var(--c2) 66% 75%,var(--c1) 76% 85%,var(--c2) 86% 95%,
#0000 96%;
background:
radial-gradient(50% 50% at 100% 0,var(--_g)),
radial-gradient(50% 50% at 0 100%,var(--_g)),
radial-gradient(50% 50%,var(--_g)),
radial-gradient(50% 50%,var(--_g)) calc(var(--s)/2) calc(var(--s)/2)
var(--c1);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p6,
.pattern-p6 {
--s: 120px; /* control the size*/
--c1: #fa6900;
--c2: #d95b43;
--c3: #ecd078;
background:
conic-gradient(from 150deg at 50% 33%,#0000,var(--c1) .5deg 60deg,#0000 60.5deg)
calc(var(--s)/2) calc(var(--s)/sqrt(2)),
conic-gradient(from -30deg at 50% 66%,#0000,var(--c2) .5deg 60deg,var(--c3) 60.5deg);
background-size: var(--s) calc(.5*var(--s)/tan(30deg));
}
html .app-shell.pattern-p7,
.pattern-p7 {
--s: 100px; /* control the size*/
--c1: #4ecdc4;
--c2: #556270;
background:
linear-gradient(atan(-.5),var(--c1) 33%,var(--c2) 33.5% 66.5%,var(--c1) 67%)
0/var(--s) var(--s);
}
html .app-shell.pattern-p8,
.pattern-p8 {
--s: 100px; /* control the size*/
--c1: #eceddc;
--c2: #29ab87;
--_g: var(--c1) 90deg,#0000 90.5deg;
background:
conic-gradient(from -45deg,var(--_g)),
conic-gradient(from 135deg,var(--_g)) calc(var(--s)/2) 0,
var(--c2);
background-size: var(--s) var(--s)
}
html .app-shell.pattern-p9,
.pattern-p9 {
--s: 150px; /* control the size*/
--c1: #ff847c;
--c2: #e84a5f;
--c3: #fecea8;
--c4: #99b898;
background:
conic-gradient(from 45deg at 75% 75%, var(--c3) 90deg,var(--c1) 0 180deg,#0000 0),
conic-gradient(from -45deg at 25% 25%, var(--c3) 90deg,#0000 0),
conic-gradient(from -45deg at 50% 100%,#0000 180deg,var(--c3) 0),
conic-gradient(from -45deg,var(--c1) 90deg, var(--c2) 0 225deg,var(--c4) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p10,
.pattern-p10 {
--s: 222px; /* control the size*/
--c1: #7f727b;
--c2: #d6b4c2;
--c3: #baa0ab;
--_g: var(--c1) 10%,var(--c2) 10.5% 19%,#0000 19.5% 80.5%,var(--c2) 81% 89.5%,var(--c3) 90%;
--_c: from -90deg at 37.5% 50%,#0000 75%;
--_l1: linear-gradient(145deg,var(--_g));
--_l2: linear-gradient( 35deg,var(--_g));
background:
var(--_l1), var(--_l1) calc(var(--s)/2) var(--s),
var(--_l2), var(--_l2) calc(var(--s)/2) var(--s),
conic-gradient(var(--_c),var(--c1) 0) calc(var(--s)/8) 0,
conic-gradient(var(--_c),var(--c3) 0) calc(var(--s)/2) 0,
linear-gradient(90deg,var(--c3) 38%,var(--c1) 0 50%,var(--c3) 0 62%,var(--c1) 0);
background-size: var(--s) calc(2*var(--s)/3);
}
html .app-shell.pattern-p11,
.pattern-p11 {
--s: 20px; /* control the size*/
--c1: #c02942;
--c2: #53777a;
--c3: #ecd078;
--g: var(--s); /* gap between lines */
background:
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 270deg,var(--c1) 0) calc(var(--s) + var(--g)) 0,
linear-gradient(var(--c2) var(--s),#0000 0) 0 var(--g),
conic-gradient(at var(--s) calc(100% - var(--s)),#0000 90deg,var(--c2) 0 180deg, var(--c1) 0),
var(--c3);
background-size: calc(2*(var(--s) + var(--g))) calc(2*(var(--s) + var(--g)));
}
html .app-shell.pattern-p12,
.pattern-p12 {
--s: 200px; /* control the size*/
--c1: #ffffff;
--c2: #1095c1;
--_g: #0000 8%,var(--c1) 0 17%,#0000 0 58%;
background:
linear-gradient(135deg,#0000 20.5%,var(--c1) 0 29.5%,#0000 0) 0 calc(var(--s)/4),
linear-gradient( 45deg,var(--_g)) calc(var(--s)/2) 0,
linear-gradient(135deg,var(--_g),var(--c1) 0 67%,#0000 0),
linear-gradient( 45deg,var(--_g),var(--c1) 0 67%,#0000 0 83%,var(--c1) 0 92%,#0000 0),
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p13,
.pattern-p13 {
--s: 64px; /* control the size*/
--c1: #e0e4cc;
--c2: #69d2e7;
--_g: 90deg,#0000 0;
background:
conic-gradient(from 135deg,var(--c1) var(--_g)) var(--s) calc(var(--s)/2),
conic-gradient(from 135deg,var(--c2) var(--_g)),
conic-gradient(from 135deg at 50% 0,var(--c1) var(--_g)) var(--c2);
background-size: calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p14,
.pattern-p14 {
--s: 64px; /* control the size*/
--c1: #eb6841;
--c2: #edc951;
background:
conic-gradient(from -45deg,var(--c1) 90deg,#0000 0 180deg,var(--c2) 0 270deg,#0000 0)
0 calc(var(--s)/2)/var(--s) var(--s),
conic-gradient(from 135deg at 50% 0,var(--c1) 90deg,var(--c2) 0)
0 0/calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p15,
.pattern-p15 {
--s: 36px; /* control the size*/
--c1: #b62f31;
--c2: #ecdacb;
--c3: #8e1f08;
--_g: calc(2*var(--s)*1.732) calc(2*var(--s))
conic-gradient(from 60deg at 62.5% 50%,var(--c3) 60deg,#0000 0);
background:
calc( 2.598*var(--s)/2) calc(var(--s)/ 2)/var(--_g),
calc(-0.866*var(--s)/2) calc(var(--s)/-2)/var(--_g),
repeating-conic-gradient(var(--c2) 0 90deg,#0000 0 180deg)
0 0/calc(2*var(--s)*1.732) calc(2*var(--s)),
repeating-conic-gradient(from 60deg,var(--c1) 0 60deg,var(--c2) 0 180deg)
0 0/calc(var(--s)*1.732) var(--s);
}
html .app-shell.pattern-p16,
.pattern-p16 {
--s: 125px; /* control the size*/
--c1: #f8ca00;
--c2: #bd1550;
--_g: var(--c1) 90deg,var(--c2) 0 135deg,#0000 0;
background:
conic-gradient(from -45deg at calc(100%/3) calc(100%/3) ,var(--c1) 90deg,#0000 0 ),
conic-gradient(from -135deg at calc(100%/3) calc(2*100%/3),var(--_g)),
conic-gradient(from 135deg at calc(2*100%/3) calc(2*100%/3),var(--_g)),
conic-gradient(from 45deg at calc(2*100%/3) calc(100%/3) ,var(--_g),var(--c1) 0 225deg,var(--c2) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p17,
.pattern-p17 {
--s: 120px; /* control the size*/
--c1: #72e21f;
--c2: #044012;
--_c: var(--c1) 25%,#0000 0;
--_g1: conic-gradient(at 62.5% 12.5%,var(--_c));
--_g2: conic-gradient(at 87.5% 62.5%,var(--_c));
--_g3: conic-gradient(at 25% 12.5%,var(--_c));
background:
var(--_g1) calc( var(--s)/-8) calc(var(--s)/2),var(--_g1) calc(-3*var(--s)/8) calc(var(--s)/4),
var(--_g2) calc(3*var(--s)/8) calc(var(--s)/4),var(--_g2) calc( var(--s)/-8) 0,
var(--_g3) 0 calc(var(--s)/-4),var(--_g3) calc(var(--s)/-4) 0,
conic-gradient(at 87.5% 87.5%,var(--_c)) calc(var(--s)/8) 0
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p18,
.pattern-p18 {
--s: 170px; /* control the size*/
--c1: #3b8686;
--c2: #cff09e;
--_c: #0000 75%,var(--c1) 0;
--_g1: conic-gradient(at 20.71% 50%,var(--_c));
--_g2: conic-gradient(at 50% 20.71%,var(--_c));
background:
var(--_g1) calc(var(--s)/ 6.828) calc(var(--s)/2),var(--_g1) calc(var(--s)/-2.828) 0,
conic-gradient(from 45deg at 29.29% 29.29%,var(--_c)) calc(var(--s)/-6.828) 0,
var(--_g2) calc(var(--s)/2) calc(var(--s)/-2.828),var(--_g2) 0 calc(var(--s)/ 6.828),
conic-gradient(from -135deg at 29.29% 70.71%,var(--_c)) calc(var(--s)/-6.828) 0,
var(--c2);
background-size: var(--s) var(--s), var(--s) var(--s), calc(var(--s)/2) calc(var(--s)/2);
}
html .app-shell.pattern-p19,
.pattern-p19 {
--s: 76px; /* control the size*/
--c1: #f6d86b;
--c2: #f10c49;
background:
conic-gradient(
var(--c2) atan(2), var(--c1) 0 calc(180deg - atan(2)),
var(--c2) 0 180deg,var(--c1) 0 calc(180deg + atan(2)),
var(--c2) 0 calc(360deg - atan(2)),var(--c1) 0);
background-size: calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p20,
.pattern-p20 {
--s: 40px; /* control the size*/
--c1: #c0d860;
--c2: #604848;
--_c: #0000 75%,var(--c1) 0;
--_g1: conic-gradient(at 10% 50%,var(--_c));
--_g2: conic-gradient(at 50% 10%,var(--_c));
background:
var(--_g1),
var(--_g1) calc(1*var(--s)) calc(3*var(--s)),
var(--_g1) calc(2*var(--s)) calc(1*var(--s)),
var(--_g1) calc(3*var(--s)) calc(4*var(--s)),
var(--_g1) calc(4*var(--s)) calc(2*var(--s)),
var(--_g2) 0 calc(4*var(--s)),
var(--_g2) calc(1*var(--s)) calc(2*var(--s)),
var(--_g2) calc(2*var(--s)) 0,
var(--_g2) calc(3*var(--s)) calc(3*var(--s)),
var(--_g2) calc(4*var(--s)) calc(1*var(--s)),
var(--c2);
background-size: calc(5*var(--s)) calc(5*var(--s));
}
html .app-shell.pattern-p21,
.pattern-p21 {
--s: 65px; /* control the size*/
--c1: #dadee1;
--c2: #4a99b4;
--c3: #9cceb5;
--_c: 75%,var(--c3) 52.72deg,#0000 0;
--_g1: conic-gradient(from -116.36deg at 25% var(--_c));
--_g2: conic-gradient(from 63.43deg at 75% var(--_c));
background:
var(--_g1), var(--_g1) calc(3*var(--s)) calc(var(--s)/2),
var(--_g2), var(--_g2) calc(3*var(--s)) calc(var(--s)/2),
conic-gradient(
var(--c2) 63.43deg ,var(--c1) 0 116.36deg,
var(--c2) 0 180deg ,var(--c1) 0 243.43deg,
var(--c2) 0 296.15deg,var(--c1) 0);
background-size: calc(2*var(--s)) var(--s);
}
html .app-shell.pattern-p22,
.pattern-p22 {
--s: 82px; /* control the size*/
--c1: #b2b2b2;
--c2: #ffffff;
--c3: #d9d9d9;
--_g: var(--c3) 0 120deg,#0000 0;
background:
conic-gradient(from -60deg at 50% calc(100%/3),var(--_g)),
conic-gradient(from 120deg at 50% calc(200%/3),var(--_g)),
conic-gradient(from 60deg at calc(200%/3),var(--c3) 60deg,var(--c2) 0 120deg,#0000 0),
conic-gradient(from 180deg at calc(100%/3),var(--c1) 60deg,var(--_g)),
linear-gradient(90deg,var(--c1) calc(100%/6),var(--c2) 0 50%,
var(--c1) 0 calc(500%/6),var(--c2) 0);
background-size: calc(1.732*var(--s)) var(--s);
}
html .app-shell.pattern-p23,
.pattern-p23 {
--s: 50px; /* control the size*/
--c1: #0b2e59;
--c2: #0d6759;
background:
conic-gradient(at 25% 25%,#0000 75%,var(--c1) 0) var(--s) var(--s),
repeating-conic-gradient(at 25% 25%,var(--c1) 0 25%,var(--c2) 0 50%);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p24,
.pattern-p24 {
--s: 100px; /* control the size*/
--c1: #1bb0ce;
--c2: #dad6ca;
--_g1:
var(--c1) calc(25%/3) ,#0000 0 calc(50%/3),
var(--c1) 0 25% ,#0000 0 75%,
var(--c1) 0 calc(250%/3),#0000 0 calc(275%/3),
var(--c1) 0;
--_g2:
#0000 calc(25%/3) ,var(--c1) 0 calc(50%/3),
#0000 0 calc(250%/3),var(--c1) 0 calc(275%/3),
#0000 0;
background:
linear-gradient( 45deg,var(--_g2)),linear-gradient( 45deg,var(--_g1)),
linear-gradient(-45deg,var(--_g2)),linear-gradient(-45deg,var(--_g1))
var(--c2); /* second color */
background-position: 0 0,var(--s) var(--s);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p25,
.pattern-p25 {
--s: 100px; /* control the size*/
--c1: #c3ccaf;
--c2: #67434f;
--_s: calc(2*var(--s)) calc(2*var(--s));
--_g: var(--_s) conic-gradient(at 40% 40%,#0000 75%,var(--c1) 0);
--_p: var(--_s) conic-gradient(at 20% 20%,#0000 75%,var(--c2) 0);
background:
calc( .9*var(--s)) calc( .9*var(--s))/var(--_p),
calc(-.1*var(--s)) calc(-.1*var(--s))/var(--_p),
calc( .7*var(--s)) calc( .7*var(--s))/var(--_g),
calc(-.3*var(--s)) calc(-.3*var(--s))/var(--_g),
conic-gradient(from 90deg at 20% 20%,var(--c2) 25%,var(--c1) 0)
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p26,
.pattern-p26 {
--s: 106px; /* control the size*/
--c1: #eafde6;
--c2: #519548;
background:
conic-gradient(from 30deg at 87.5% 75%,var(--c1) 60deg,var(--c2) 0 120deg,#0000 0) 0 calc(.2165*var(--s)),
conic-gradient(from -90deg at 50% 25%,var(--c2) 60deg,var(--c1) 0 180deg,#0000 0),
conic-gradient(from 90deg at 50% 75%,var(--c2) 120deg,var(--c1) 0 180deg,#0000 0),
conic-gradient(from -30deg at 87.5% 50%,var(--c2) 120deg,var(--c1) 0 240deg,#0000 0),
conic-gradient(from 90deg at 37.5% 50%,var(--c2) 120deg,var(--c1) 0 180deg,var(--c2) 0 240deg,var(--c1) 0);
background-size: var(--s) calc(.866*var(--s));
}
html .app-shell.pattern-p27,
.pattern-p27 {
--s: 60px; /* control the size*/
--c1: #b09f79;
--c2: #476074;
--_g: #0000 83%,var(--c1) 85% 99%,#0000 101%;
background:
radial-gradient(27% 29% at right ,var(--_g)) calc(var(--s)/ 2) var(--s),
radial-gradient(27% 29% at left ,var(--_g)) calc(var(--s)/-2) var(--s),
radial-gradient(29% 27% at top ,var(--_g)) 0 calc(var(--s)/ 2),
radial-gradient(29% 27% at bottom,var(--_g)) 0 calc(var(--s)/-2)
var(--c2);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p28,
.pattern-p28 {
--s: 80px; /* control the size*/
--c1: #5e8c6a;
--c2: #bfb35a;
--_s: calc(2*var(--s)) calc(2*var(--s));
--_g: 35.36% 35.36% at;
--_c: #0000 66%,var(--c2) 68% 70%,#0000 72%;
background:
radial-gradient(var(--_g) 100% 25%,var(--_c)) var(--s) var(--s)/var(--_s),
radial-gradient(var(--_g) 0 75%,var(--_c)) var(--s) var(--s)/var(--_s),
radial-gradient(var(--_g) 100% 25%,var(--_c)) 0 0/var(--_s),
radial-gradient(var(--_g) 0 75%,var(--_c)) 0 0/var(--_s),
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
radial-gradient(var(--_c)) 0 calc(var(--s)/2)/var(--s) var(--s)
var(--c1);
}
html .app-shell.pattern-p29,
.pattern-p29 {
--s: 150px; /* control the size*/
--c1: #ccbf82;
--c2: #604848;
--_g: var(--c1) 6.1%,var(--c2) 6.4% 18.6%,var(--c1) 18.9% 31.1%,
var(--c2) 31.4% 43.6%,var(--c1) 43.9% 56.1%,var(--c2) 56.4% 68.6%,#0000 68.9%;
background:
radial-gradient(var(--s) at 100% 0 ,var(--_g)),
radial-gradient(var(--s) at 0 0 ,var(--_g)),
radial-gradient(var(--s) at 0 100%,var(--_g)),
radial-gradient(var(--s) at 100% 100%,var(--_g)) var(--c1);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p30,
.pattern-p30 {
--s: 150px; /* control the size*/
--c1: #046d8b;
--c2: #2fb8ac;
--_g: #0000 90deg,var(--c1) 0;
background:
conic-gradient(from 116.56deg at calc(100%/3) 0 ,var(--_g)),
conic-gradient(from -63.44deg at calc(200%/3) 100%,var(--_g))
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p31,
.pattern-p31 {
--s: 60px; /* control the size*/
--c1: #ffdc56;
--c2: #fe6601;
--c3: #803201;
--_s: calc(2*var(--s)) calc(2*var(--s));
--_g1: var(--_s) conic-gradient(at calc(500%/6) 50%,var(--c3) 25%,#0000 0);
--_g2: var(--_s) conic-gradient(at calc(200%/3) 50%,var(--c2) 25%,#0000 0);
background:
var(--s) var(--s)/var(--_g1),0 0/var(--_g1),
var(--s) var(--s)/var(--_g2),0 0/var(--_g2),
repeating-conic-gradient(var(--c1) 0 25%,#0000 0 50%) 0 0/var(--_s),
linear-gradient(var(--c1) calc(100%/3),var(--c2) 0 calc(200%/3),var(--c3) 0)
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p32,
.pattern-p32 {
--s: 50px; /* control the size*/
--c1: #6963bb;
--c2: #99ffc0;
--c3: #ffd6d6;
--_g: conic-gradient(at 50% 25%,#0000 75%,var(--c1) 0);
background:
var(--_g),var(--_g) var(--s) var(--s),
var(--_g) calc(2*var(--s)) calc(2*var(--s)),
var(--_g) calc(3*var(--s)) calc(3*var(--s)),
repeating-linear-gradient(135deg,var(--c2) 0 12.5%,var(--c3) 0 25%);
background-size: calc(4*var(--s)) calc(4*var(--s))
}
html .app-shell.pattern-p33,
.pattern-p33 {
--s: 100px; /* control the size*/
--c1: #668284;
--c2: #b6d8c0;
--c3: #b9d7d9;
--_g: #0000, var(--c1) 1deg 30deg,var(--c2) 31deg 89deg,var(--c1) 90deg 119deg,#0000 120deg;
background:
conic-gradient(from -60deg at 50% 28.86%,var(--_g)),
conic-gradient(from 30deg at 71.14% 50%,var(--_g)),
conic-gradient(from 120deg at 50% 71.14%,var(--_g)),
conic-gradient(from 210deg at 28.86% 50%,var(--_g))
var(--c3);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p34,
.pattern-p34 {
--s: 100px; /* control the size*/
--c1: #fcd036;
--c2: #987f69;
--_g: var(--c1) 0 100%,#0000 102%;
background:
conic-gradient(#0000 75%,var(--_g)) calc(var(--s)/4) calc(var(--s)/4),
radial-gradient(65% 65% at 50% -50%,var(--_g)),
radial-gradient(65% 65% at -50% 50%,var(--_g)),
radial-gradient(65% 65% at 50% 150%,var(--_g)),
radial-gradient(65% 65% at 150% 50%,var(--_g))
var(--c2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p35,
.pattern-p35 {
--s: 76px; /* control the size*/
--c1: #1a144a;
--c2: #2eb044;
--_g: conic-gradient(var(--c1) 25%,#0000 0) 0 0;
background:
var(--_g)/calc(2*var(--s)) calc(var(--s)/9.5),
var(--_g)/calc(var(--s)/9.5) calc(2*var(--s)),
repeating-conic-gradient(#0000 0 25%,var(--c1) 0 50%)
var(--s) 0 /calc(2*var(--s)) calc(2*var(--s)),
radial-gradient(50% 50%,var(--c2) 98%,var(--c1))
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p36,
.pattern-p36 {
--s: 70px; /* control the size*/
--c1: #6b5344;
--c2: #f8ecc9;
--_l: #0000 46%,var(--c1) 47% 53%,#0000 54%;
background:
radial-gradient(100% 100% at 100% 100%,var(--_l)) var(--s) var(--s),
radial-gradient(100% 100% at 0 0 ,var(--_l)) var(--s) var(--s),
radial-gradient(100% 100%,#0000 22%,var(--c1) 23% 29%, #0000 30% 34%,var(--c1) 35% 41%,#0000 42%),
var(--c2);
background-size: calc(var(--s)*2) calc(var(--s)*2);
}
html .app-shell.pattern-p37,
.pattern-p37 {
--s: 80px; /* control the size*/
--c1: #542437;
--c2: #c02942;
--_g:
#0000 calc(-650%/13) calc(50%/13),var(--c1) 0 calc(100%/13),
#0000 0 calc(150%/13),var(--c1) 0 calc(200%/13),
#0000 0 calc(250%/13),var(--c1) 0 calc(300%/13);
--_g0: repeating-linear-gradient( 45deg,var(--_g));
--_g1: repeating-linear-gradient(-45deg,var(--_g));
background:
var(--_g0),var(--_g0) var(--s) var(--s),
var(--_g1),var(--_g1) var(--s) var(--s) var(--c2);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p38,
.pattern-p38 {
--s: 56px; /* control the size*/
--c1: #3fb8af;
--c2: #ff9e9d;
--_c1: var(--c1) 99%,#0000 101%;
--_c2: var(--c2) 99%,#0000 101%;
--r:calc(var(--s)*.866);
--g0:radial-gradient(var(--s),var(--_c1));
--g1:radial-gradient(var(--s),var(--_c2));
--f:radial-gradient(var(--s) at calc(100% + var(--r)) 50%,var(--_c1));
--p:radial-gradient(var(--s) at 100% 50%,var(--_c2));
background:
var(--f) 0 calc(-5*var(--s)/2),
var(--f) calc(-2*var(--r)) calc(var(--s)/2),
var(--p) 0 calc(-2*var(--s)),
var(--g0) var(--r) calc(-5*var(--s)/2),
var(--g1) var(--r) calc( 5*var(--s)/2),
radial-gradient(var(--s) at 100% 100%,var(--_c1)) 0 calc(-1*var(--s)),
radial-gradient(var(--s) at 0% 50% ,var(--_c1)) 0 calc(-4*var(--s)),
var(--g1) calc(-1*var(--r)) calc(-7*var(--s)/2),
var(--g0) calc(-1*var(--r)) calc(-5*var(--s)/2),
var(--p) calc(-2*var(--r)) var(--s),
var(--g0) calc(-1*var(--r)) calc(var(--s)/ 2),
var(--g1) calc(-1*var(--r)) calc(var(--s)/-2),
var(--g0) 0 calc(-1*var(--s)),
var(--g1) var(--r) calc(var(--s)/-2),
var(--g0) var(--r) calc(var(--s)/ 2)
var(--c2);
background-size: calc(4*var(--r)) calc(6*var(--s));
}
html .app-shell.pattern-p39,
.pattern-p39 {
--s: 50px; /* control the size*/
--c1: #cff09e;
--c2: #0b486b;
--_g: radial-gradient(var(--c2) 49%,#0000 50%);
background:
var(--_g) calc(var(--s)/-2) calc(var(--s)/2),
repeating-conic-gradient(from 45deg,var(--c1) 0 25%,#0000 0 50%)
calc(var(--s)/2) calc(var(--s)/2),
var(--_g),var(--_g) var(--s) var(--s) var(--c1);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p40,
.pattern-p40 {
--s: 75px; /* control the size*/
--c1: #1fffa9;
--c2: #a31e39;
--c3: #31570e;
--_c:#0000,var(--c1) 1deg 79deg,#0000 81deg;
--g0:conic-gradient(from 140deg at 50% 87.5% ,var(--_c));
--g1:conic-gradient(from 140deg at 50% 81.25%,var(--_c));
--g2:conic-gradient(from 140deg at 50% 75% ,var(--_c));
--g3:conic-gradient(at 10% 20%,#0000 75%,var(--c1) 0);
background:
var(--g0) 0 calc(var(--s)/-4),var(--g0) var(--s) calc(3*var(--s)/4),
var(--g1) ,var(--g1) var(--s) var(--s),
var(--g2) 0 calc(var(--s)/ 4),var(--g2) var(--s) calc(5*var(--s)/4),
var(--g3) calc( var(--s)/-10) var(--s),
var(--g3) calc(9*var(--s)/10) calc(2*var(--s)),
repeating-conic-gradient(from 45deg,var(--c2) 0 25%,var(--c3) 0 50%);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p41,
.pattern-p41 {
--s: 150px; /* control the size*/
--c1: #5e412f;
--c2: #fcebb6;
background:
linear-gradient(135deg,#0000 18.75%,var(--c1) 0 31.25%,#0000 0),
repeating-linear-gradient(45deg,var(--c1) -6.25% 6.25%,var(--c2) 0 18.75%);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p42,
.pattern-p42 {
--s: 120px; /* control the size*/
--c1: #1a2030;
--c2: #0f9177;
--c3: #fdebad;
--c4: #d34434;
--c5: #b5d999;
--_g: radial-gradient(#0000 70%,var(--c1) 71%);
background:
var(--_g),var(--_g) calc(var(--s)/2) calc(var(--s)/2),
conic-gradient(var(--c2) 25%,var(--c3) 0 50%,var(--c4) 0 75%,var(--c5) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p43,
.pattern-p43 {
--s: 34px; /* control the size*/
--c1: #ecbe13;
--c2: #309292;
--_g: radial-gradient(calc(var(--s)/2),var(--c1) 97%,#0000);
background:
var(--_g),var(--_g) calc(2*var(--s)) calc(2*var(--s)),
repeating-conic-gradient(from 45deg,#0000 0 25%,var(--c2) 0 50%) calc(-.707*var(--s)) calc(-.707*var(--s)),
repeating-linear-gradient(135deg,var(--c1) calc(var(--s)/-2) calc(var(--s)/2),var(--c2) 0 calc(2.328*var(--s)));
background-size: calc(4*var(--s)) calc(4*var(--s));
}
html .app-shell.pattern-p44,
.pattern-p44 {
--s: 60px; /* control the size*/
--c1: #7bb0a8;
--c2: #a7dbab;
--_c: var(--c1) calc(100% - var(--s)/2) 99%,#0000;
--_g: var(--s),#0000 calc(99% - var(--s)/2),var(--_c);
background:
radial-gradient(var(--s) at 100% var(--_g)),
radial-gradient(calc(var(--s)/4) at 50% calc(100%/3),var(--_c)) var(--s) 0,
radial-gradient(var(--s) at 0% var(--_g)) 0 calc(3*var(--s))
var(--c2);
background-size:
calc(2*var(--s)) calc(9*var(--s)/4),
calc(2*var(--s)) calc(3*var(--s)/4);
}
html .app-shell.pattern-p45,
.pattern-p45 {
--s: 30px; /* control the size*/
--c1: #e5fcc2;
--c2: #45ada8;
--_s:37.5% 12.5% at 62.5%;
--_g:34% 99%,#0000 101%;
--g1:radial-gradient(var(--_s) 100%,#0000 32%,var(--c1) var(--_g));
--g2:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c1) var(--_g));
--g3:radial-gradient(var(--_s) 100%,#0000 32%,var(--c2) var(--_g));
--g4:radial-gradient(var(--_s) 0 ,#0000 32%,var(--c2) var(--_g));
background:
var(--g1),
var(--g2) 0 calc(3*var(--s)),
var(--g3) var(--s) calc(3*var(--s)),
var(--g4) var(--s) calc(6*var(--s)),
var(--g1) calc(2*var(--s)) calc(6*var(--s)),
var(--g2) calc(2*var(--s)) calc(9*var(--s)),
var(--g3) calc(3*var(--s)) calc(9*var(--s)),
var(--g4) calc(3*var(--s)) 0,
repeating-linear-gradient(var(--c1) 0 25%,var(--c2) 0 50%);
background-size: calc(4*var(--s)) calc(12*var(--s));
}
html .app-shell.pattern-p46,
.pattern-p46 {
--s: 65px; /* control the size*/
--c1: #ecd078;
--c2: #0b486b;
--g: calc(var(--s)/5); /* control the gap */
--_l: #0000 calc(33% - .866*var(--g)),var(--c1) calc(33.2% - .866*var(--g)) 33%,#0000 34%;
background:
repeating-linear-gradient(var(--c1) 0 var(--g), #0000 0 50%)
0 calc(.866*var(--s) - var(--g)/2),
conic-gradient(from -150deg at var(--g) 50%,var(--c1) 120deg,#0000 0),
linear-gradient(-120deg,var(--_l)),linear-gradient( -60deg,var(--_l))
var(--c2);
background-size: var(--s) calc(3.466*var(--s))
}
html .app-shell.pattern-p47,
.pattern-p47 {
--s: 100px; /* control the size*/
--c1: #f2e9e1;
--c2: #99b2b7;
background:
repeating-conic-gradient(var(--c1) 0 45deg,var(--c2) 0 90deg)
0/var(--s) var(--s);
}
html .app-shell.pattern-p48,
.pattern-p48 {
--s: 50px; /* control the size*/
--c1: #d9ceb2;
--c2: #948c75;
--_g: calc(2*var(--s)) calc(2*var(--s))
radial-gradient(25% 25%,var(--c1) 99%,#0000 101%);
background:
0 var(--s)/var(--_g),var(--s) 0/var(--_g),
radial-gradient(50% 50%,var(--c2) 97%,#0000)
calc(var(--s)/2) calc(var(--s)/2)/var(--s) var(--s),
linear-gradient(90deg,var(--c1) 50%,var(--c2) 0)
0 0/calc(2*var(--s));
}
html .app-shell.pattern-p49,
.pattern-p49 {
--s: 100px; /* control the size*/
--c1: #5a3d31;
--c2: #e5edb8;
background:
radial-gradient(#0000 50%,var(--c1) 52% 55%,var(--c2) 57%)
0 0/var(--s) var(--s),
repeating-linear-gradient(45deg,var(--c1) 0 25%,var(--c2) 0 50%)
0 0/calc(2*var(--s)) calc(2*var(--s))
}
html .app-shell.pattern-p50,
.pattern-p50 {
--s: 50px; /* control the size*/
--c1: #e08e79;
--c2: #774f38;
--c: var(--c1) 0 25%,var(--c2) 0 50%,#0000 0;
background:
conic-gradient(from 180deg,var(--c))
0/var(--s) var(--s),
repeating-conic-gradient(from 90deg,var(--c))
0/calc(3*var(--s)) calc(3*var(--s));
}
html .app-shell.pattern-p51,
.pattern-p51 {
--s: 100px; /* control the size*/
--c1: #cc2a41;
--c2: #351330;
--_g: #0000 24%,
var(--c2) 26% 34%,var(--c1) 36% 44%,
var(--c2) 46% 54%,var(--c1) 56% 64%,
var(--c2) 66% 74%,#0000 76%;
background:
radial-gradient(100% 100% at 100% 0,var(--_g)),
radial-gradient(100% 100% at 0 100%,var(--_g)),
radial-gradient(var(--c2) 14%,var(--c1) 16%)
calc(var(--s)/2) calc(var(--s)/2);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p52,
.pattern-p52 {
--s: 29px; /* control the size*/
--c1: #615375;
--c2: #8eb2c5;
--g:#0000 calc(100% - var(--s)/3 - 1px),var(--c1)
calc(100% - var(--s)/3) calc(100% - 1px),#0000;
--r:calc(2.414*var(--s));
--p:calc(1.414*var(--s));
background:
radial-gradient(var(--r) at 0 0 ,var(--g)) var(--r) var(--r),
radial-gradient(var(--s) at 0 0 ,var(--g)) var(--p) var(--p),
radial-gradient(var(--r) at 0 100%,var(--g)) 0 var(--p),
radial-gradient(var(--s) at 0 100%,var(--g)) calc(-1*var(--s)) var(--r),
radial-gradient(var(--r) at 100% 0 ,var(--g)) var(--p) 0,
radial-gradient(var(--s) at 100% 0 ,var(--g)) var(--r) calc(-1*var(--s)),
radial-gradient(var(--r) at 100% 100%,var(--g)) calc(-1*var(--s)) calc(-1*var(--s)),
radial-gradient(var(--s) at 100% 100%,var(--g)) var(--c2);
background-size: calc(2*var(--r)) calc(2*var(--r));
}
html .app-shell.pattern-p53,
.pattern-p53 {
--s: 160px; /* control the size*/
--c1: #a14016;
--c2: #cfc89a;
--_g: var(--s) var(--s)
radial-gradient(var(--c1) 17%,var(--c2) 18% 35%,#0000 36.5%);
background:
calc(var(--s)/-4) calc(var(--s)/-4)/var(--_g),
calc(var(--s)/ 4) calc(var(--s)/ 4)/var(--_g),
radial-gradient(var(--c2) 34%,var(--c1) 36% 68%,#0000 70%)
0 0/calc(var(--s)/2) calc(var(--s)/2),
repeating-linear-gradient(45deg,var(--c1) -12.5% 12.5%,var(--c2) 0 37.5%)
0 0/var(--s) var(--s);
}
html .app-shell.pattern-p54,
.pattern-p54 {
--s: 100px; /* control the size*/
--c1: #ffd500;
--c2: #0341ae;
background:
conic-gradient(at 50% 25%,var(--c2) 25%,#0000 0)
calc(var(--s)/-4) calc(var(--s)/-2),
conic-gradient(at 25% 25%,var(--c1) 25%,#0000 0 75%,var(--c2) 0)
calc(var(--s)/4) calc(var(--s)/-4),
conic-gradient(from -90deg at 75% 25%,var(--c2) 75%,var(--c1) 0);
background-size: var(--s) var(--s);
}
html .app-shell.pattern-p55,
.pattern-p55 {
--s: 50px; /* control the size*/
--c1: #292522;
--c2: #efd9b4;
--c: #0000 75%,var(--c1) 0;
--g1: conic-gradient(at 75% 25%,var(--c));
--g2: conic-gradient(at 25% 75%,var(--c));
background:
var(--g1),var(--g1) var(--s) var(--s),
var(--g2),var(--g2) var(--s) var(--s) var(--c2);
background-size: calc(2*var(--s)) calc(2*var(--s));
}
html .app-shell.pattern-p56,
.pattern-p56 {
--s: 6px; /* control the size*/
--c1: #3b2d38;
--c2: #f02475;
--g:#0000 66%,var(--c1) 68% 98%,#0000;
background:
radial-gradient(30% 30% at 0% 30%,var(--g))
var(--s) calc(9*var(--s)),
radial-gradient(30% 30% at 100% 30%,var(--g))
var(--s) calc(-1*var(--s)),
radial-gradient(30% 30% at 30% 100%,var(--g))
calc(10*var(--s)) 0,
radial-gradient(30% 30% at 30% 0% ,var(--g))
var(--c2);
background-size: calc(20*var(--s)) calc(20*var(--s));
}
html .app-shell.pattern-p57,
.pattern-p57 {
--s: 194px; /* control the size*/
--c1: #f6edb3;
--c2: #acc4a3;
--c3: #55897c;
--_l:#0000 calc(25%/3),var(--c1) 0 25%,#0000 0;
--_g:conic-gradient(from 120deg at 50% 87.5%,var(--c1) 120deg,#0000 0);
background:
var(--_g),var(--_g) 0 calc(var(--s)/2),
conic-gradient(from 180deg at 75%,var(--c2) 60deg,#0000 0),
conic-gradient(from 60deg at 75% 75%,var(--c1) 0 60deg,#0000 0),
linear-gradient(150deg,var(--_l)) 0 calc(var(--s)/2),
conic-gradient(at 25% 25%,#0000 50%,var(--c2) 0 240deg,var(--c1) 0 300deg,var(--c2) 0),
linear-gradient(-150deg,var(--_l)) var(--c3);
background-size: calc(0.866*var(--s)) var(--s);
}
+61
View File
@@ -0,0 +1,61 @@
// Theme helpers — derive subtle pattern shades from a user's chosen color
// swatch. Patterns in theme-patterns.css read --c1..--c4; we override them
// with hsl variations of the user's hue so the background stays subtle.
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return { h: 243, s: 75, l: 66 }; // indigo fallback
const n = parseInt(m[1], 16);
const r = ((n >> 16) & 255) / 255;
const g = ((n >> 8) & 255) / 255;
const b = (n & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
// Four subtle shades built on the swatch's hue — pastel tints distinct
// enough that the pattern motif reads against the near-white app shell,
// but soft enough to stay a background wash rather than a loud print.
// `alpha` (0..1) fades the whole motif toward the shell background — the
// app-shell keeps `background: whitesmoke` underneath, so lowering it
// blends the pattern out.
export function themeShades(
hex: string,
alpha = 1
): { c1: string; c2: string; c3: string; c4: string } {
const { h } = hexToHsl(hex);
return {
c1: `hsl(${h} 45% 93% / ${alpha})`,
c2: `hsl(${h} 50% 85% / ${alpha})`,
c3: `hsl(${h} 55% 74% / ${alpha})`,
c4: `hsl(${(h + 40) % 360} 50% 87% / ${alpha})`
};
}
// Pattern ids: 'p1'..'p60', '' = none. Currently showing the first 19;
// bump this to include more (the CSS file still ships all 60).
export const PATTERN_COUNT = 19;
export function patternId(n: number): string {
return `p${n}`;
}
// Pattern size slider — the value is the pattern's `--s` in vw units, so the
// motif scales with the viewport. E.g. 7 at a 1400px screen ≈ 98px tiles.
export const THEME_SIZE_MIN = 2;
export const THEME_SIZE_MAX = 16;
export const THEME_SIZE_DEFAULT = 7;
// Pattern opacity slider — 1 = current full visibility, down to a faint hint.
export const THEME_OPACITY_MIN = 0.05;
export const THEME_OPACITY_MAX = 1;
export const THEME_OPACITY_STEP = 0.05;
export const THEME_OPACITY_DEFAULT = 1;
+5
View File
@@ -643,4 +643,9 @@
font-size: 2rem; font-size: 2rem;
} }
} }
@media (max-width: 639px) {
h1 {
font-size: 1.5em;
}
}
</style> </style>
+14 -1
View File
@@ -2,6 +2,8 @@ import { redirect } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase'; import { createPbClient } from '$lib/server/pocketbase';
import { createServices, type ChatActor } from '$lib/server/services'; import { createServices, type ChatActor } from '$lib/server/services';
import { ensureFamAccess } from '$lib/server/access'; import { ensureFamAccess } from '$lib/server/access';
import { seedDemoCompletions } from '$lib/server/migrate';
import { getPlatformFlags } from '$lib/server/platform';
async function paydayCheck(famId: string, pbToken: string) { async function paydayCheck(famId: string, pbToken: string) {
try { try {
@@ -46,6 +48,11 @@ async function resolveChatIdentity(
} }
export async function load(event) { export async function load(event) {
// Demo family: rotate 3-week completion data on every visit when demo mode is on (idempotent).
if (event.params.fam === 'showboaters' && (await getPlatformFlags()).demo) {
seedDemoCompletions().catch(() => {});
}
const session = event.locals.user; const session = event.locals.user;
const role = session?.role || 'child'; const role = session?.role || 'child';
const isParent = role === 'parent'; const isParent = role === 'parent';
@@ -88,6 +95,8 @@ export async function load(event) {
} }
} }
const demoMode = (await getPlatformFlags()).demo;
return { return {
// Canonical fam slug — from the URL param ([fam] routes). Client code // Canonical fam slug — from the URL param ([fam] routes). Client code
// reads page.data.famSlug; never copy it into local $state. // reads page.data.famSlug; never copy it into local $state.
@@ -98,6 +107,9 @@ export async function load(event) {
userId: session.id, userId: session.id,
memberName: session.name, memberName: session.name,
memberColor: session.color || '', memberColor: session.color || '',
memberPattern: session.pattern || '',
memberThemeSize: session.themeSize || '',
memberThemeOpacity: session.themeOpacity || '',
username: session.username || '', username: session.username || '',
role: session.role role: session.role
} }
@@ -108,6 +120,7 @@ export async function load(event) {
chat, chat,
pbToken, pbToken,
fam, fam,
famAccess famAccess,
demoMode
}; };
} }
+67 -15
View File
@@ -9,12 +9,30 @@
import { Sidebar, TopNav, Footer, Chat } from '$lib/components'; import { Sidebar, TopNav, Footer, Chat } from '$lib/components';
import { chatIcon } from '$lib/components/icons'; import { chatIcon } from '$lib/components/icons';
import { recordShortcut } from '$lib/shortcut'; import { recordShortcut } from '$lib/shortcut';
import { themeShades } from '$lib/theme';
import '$lib/theme-patterns.css';
import { themeDraft } from '$lib/stores/theme.svelte';
import type { Session } from '$lib/types'; import type { Session } from '$lib/types';
let { children, data } = $props(); let { children, data } = $props();
// Background pattern ('' = none) tinted with the user's colour. A live
// draft (clicked in the picker, not yet saved) previews immediately;
// otherwise the saved value from the session applies.
let bgPattern = $derived(themeDraft.pattern ?? (data.session?.memberPattern || ''));
let bgColor = $derived(themeDraft.color ?? (data.session?.memberColor || '#6366f1'));
let bgOpacity = $derived(
themeDraft.opacity ??
(Number(data.session?.memberThemeOpacity) > 0 ? data.session?.memberThemeOpacity : 1)
);
let bgShades = $derived(themeShades(bgColor, Number(bgOpacity) || 1));
// Pattern size in vw units ('' = untouched → pattern class default).
let bgSize = $derived(themeDraft.size ?? (data.session?.memberThemeSize || ''));
let bgSizeVw = $derived(Number(bgSize) > 0 ? bgSize : '');
let session = $state<Session | null>(data.session); let session = $state<Session | null>(data.session);
let isParent = $state(data.isParent); let isParent = $state(data.isParent);
let role = $state(data.role); let role = $state(data.role);
let isDemo = $state(data.demoMode);
let famName = $derived( let famName = $derived(
famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam
); );
@@ -126,7 +144,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;
@@ -176,11 +194,14 @@
}); });
</script> </script>
<div class="layout-stage" class:chat-open={chatStore.open}> <div class="layout-stage" class:chat-open={chatStore.open} class:has-pattern={!!bgPattern}>
<div class="app-shell"> <div
<Sidebar {famName} session={data.session} {isParent} {role} /> class="app-shell {bgPattern ? `pattern-${bgPattern}` : ''}"
style={`${bgSizeVw ? `--s:calc(${bgSizeVw} * 1vw);` : ''}--c1:${bgShades.c1};--c2:${bgShades.c2};--c3:${bgShades.c3};--c4:${bgShades.c4}`}
>
<Sidebar {famName} session={data.session} {isParent} {role} {isDemo} />
<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 +212,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 +239,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 +258,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 +325,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>
@@ -1,20 +1,28 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { pbUser, createPbClient } from '$lib/server/pocketbase'; import { pbUser, createPbClient } from '$lib/server/pocketbase';
import { createServices } from '$lib/server/services'; import { createServices } from '$lib/server/services';
import { getPlatformFlags } from '$lib/server/platform';
import { pbAdmin } from '$lib/server/pocketbase';
export async function load(event) { export async function load(event) {
const session = event.locals.user; const session = event.locals.user;
const demoMode = (await getPlatformFlags()).demo;
const isDemoParent = session?.username === 'demo';
const famSlug = event.params.fam;
const username = event.params.username;
// Demo parent visiting a child's route → load child kanban view.
const demoParentVisitingChild = demoMode && isDemoParent && session?.username !== username;
// Parent (role=parent, session auth) → admin overview. // Parent (role=parent, session auth) → admin overview.
// Branch on role, NOT presence: a child who OTP-logged-in also has a // Branch on role, NOT presence: a child who OTP-logged-in also has a
// `users` session (locals.user) and must not hit the parent path. // `users` session (locals.user) and must not hit the parent path.
if (session && session.role === 'parent') { if (session && session.role === 'parent' && !demoParentVisitingChild) {
const famId = session.famId; if (!(demoMode && isDemoParent)) {
const famSlug = event.params.fam;
const username = event.params.username;
if (session.name && session.username && session.username !== username) { if (session.name && session.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`); throw redirect(303, `/${famSlug}/${session.username}`);
} }
}
const s = createServices(pbUser(event), session); const s = createServices(pbUser(event), session);
const [ const [
members, members,
@@ -27,15 +35,15 @@ export async function load(event) {
completions, completions,
settings settings
] = await Promise.all([ ] = await Promise.all([
s.crud.list('members', famId), s.crud.list('members', session.famId),
s.crud.list('chore-templates', famId), s.crud.list('chore-templates', session.famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', session.famId),
s.fam.weeklySummary(famId), s.fam.weeklySummary(session.famId),
s.fam.get(famId), s.fam.get(session.famId),
s.crud.list('rewards', famId), s.crud.list('rewards', session.famId),
s.crud.list('bonus-configs', famId), s.crud.list('bonus-configs', session.famId),
s.crud.list('completions', famId), s.crud.list('completions', session.famId),
s.settings.get(famId) s.settings.get(session.famId)
]); ]);
return { return {
role: 'parent', role: 'parent',
@@ -52,18 +60,23 @@ export async function load(event) {
}; };
} }
// Child (users auth, role=child) → kanban // Child (users auth, role=child) → kanban.
const famSlug = event.params.fam; // Also demo parent visiting a child's route.
const username = event.params.username;
// A child should always land on their own kanban route. // A child should always land on their own kanban route.
if (!(demoMode && isDemoParent)) {
if (session?.username && session.username !== username) { if (session?.username && session.username !== username) {
throw redirect(303, `/${famSlug}/${session.username}`); throw redirect(303, `/${famSlug}/${session.username}`);
} }
}
const pbToken = event.cookies.get('pb_token') || ''; const pbToken = event.cookies.get('pb_token') || '';
const famId = session?.famId || ''; const famId = session?.famId || '';
const childId = session?.id || ''; // When a demo parent visits a child's route, session.id is the parent's PB
// record. Look up the actual child user by username so chores load correctly.
const childId = demoParentVisitingChild
? (await pbAdmin.getList('users', `famId='${famId}' && username='${famSlug}:${username}'`))?.[0]?.id || ''
: session?.id || '';
const empty = { const empty = {
role: 'child' as const, role: 'child' as const,
+105 -22
View File
@@ -103,6 +103,8 @@
); );
} }
let hasClaimableRewards = $derived(claimableRewards().length > 0);
function memberClaimable(memberId: string) { function memberClaimable(memberId: string) {
return claimableRewards().filter((r: any) => r.memberId === memberId); return claimableRewards().filter((r: any) => r.memberId === memberId);
} }
@@ -144,10 +146,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 +616,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 +746,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 +937,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 +1069,7 @@
{/if} {/if}
</Card> </Card>
<Card cols={1} title="Claims" accent="#f59e0b"> <Card cols={1} title="Claims" accent={claimableRewards().length > 0 ? '#f97316' : undefined} class={claimableRewards().length > 0 ? '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 +1128,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 +1140,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 +1155,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 +1287,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 +2246,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 +2509,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 +2557,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 +2981,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}
@@ -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
bind:value={assignTarget}
onchange={() => {
if (assignTarget === 'member' && !assignModal?.memberId && members.length) { if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
assignModal = { ...assignModal!, memberId: members[0].id }; 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 = {
@@ -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>
@@ -1,7 +1,16 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { servicesFor } from '$lib/server/servicesFor'; import { servicesFor } from '$lib/server/servicesFor';
import { getPlatformFlags } from '$lib/server/platform';
async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo;
}
export async function load(event) { export async function load(event) {
if (await isDemoMode()) {
return { verified: false, role: '', token: '', memberId: '', famId: '', memberName: '', memberColor: '', pattern: '', themeSize: '', themeOpacity: '', email: '', demoMode: true };
}
const session = event.locals.user; const session = event.locals.user;
const famSlug = event.params.fam; const famSlug = event.params.fam;
const username = event.params.username; const username = event.params.username;
@@ -37,6 +46,9 @@ export async function load(event) {
famId, famId,
memberName: me.name, memberName: me.name,
memberColor: me.color, memberColor: me.color,
pattern: me.pattern || '',
themeSize: me.themeSize || '',
themeOpacity: me.themeOpacity || '',
email: me.email || '', email: me.email || '',
session: true session: true
}; };
@@ -63,6 +75,9 @@ export async function load(event) {
famId, famId,
memberName: session.name || '', memberName: session.name || '',
memberColor: session.color || '', memberColor: session.color || '',
pattern: session.pattern || '',
themeSize: session.themeSize || '',
themeOpacity: session.themeOpacity || '',
email: '', email: '',
session: true session: true
}; };
@@ -77,6 +92,9 @@ export const actions = {
const name = fd.get('name') as string; const name = fd.get('name') as string;
const color = fd.get('color') as string; const color = fd.get('color') as string;
const email = fd.get('email') as string; const email = fd.get('email') as string;
const pattern = (fd.get('pattern') as string) || '';
const themeSize = (fd.get('themeSize') as string) || '';
const themeOpacity = (fd.get('themeOpacity') as string) || '';
const s = servicesFor(event); const s = servicesFor(event);
const famId = session.famId; const famId = session.famId;
@@ -86,9 +104,20 @@ export const actions = {
const data: Record<string, string> = {}; const data: Record<string, string> = {};
if (name) data.name = name; if (name) data.name = name;
if (color) data.color = color; if (color) data.color = color;
data.pattern = pattern;
data.themeSize = themeSize;
data.themeOpacity = themeOpacity;
data.email = email || ''; data.email = email || '';
const me = await s.fam.updateProfile(famId, data); const me = await s.fam.updateProfile(famId, data);
return { success: true, name: me.name, color: me.color, email: me.email }; return {
success: true,
name: me.name,
color: me.color,
pattern: me.pattern,
themeSize: me.themeSize,
themeOpacity: me.themeOpacity,
email: me.email
};
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' }; return { error: e instanceof Error ? e.message : 'Update failed' };
} }
@@ -99,8 +128,19 @@ export const actions = {
const data: Record<string, string> = {}; const data: Record<string, string> = {};
if (name) data.name = name; if (name) data.name = name;
if (color) data.color = color; if (color) data.color = color;
data.pattern = pattern;
data.themeSize = themeSize;
data.themeOpacity = themeOpacity;
const me = await s.fam.updateProfile(famId, data); const me = await s.fam.updateProfile(famId, data);
return { success: true, name: me.name, color: me.color, email: '' }; return {
success: true,
name: me.name,
color: me.color,
pattern: me.pattern,
themeSize: me.themeSize,
themeOpacity: me.themeOpacity,
email: ''
};
} catch (e) { } catch (e) {
return { error: e instanceof Error ? e.message : 'Update failed' }; return { error: e instanceof Error ? e.message : 'Update failed' };
} }
@@ -1,12 +1,20 @@
<script lang="ts"> <script lang="ts">
import { enhance, applyAction } from '$app/forms'; import { enhance, applyAction } from '$app/forms';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import { invalidateAll } from '$app/navigation';
import { ViewHeader, CardGrid, Card, Button, PatternPicker } from '$lib/components';
import { themeDraft } from '$lib/stores/theme.svelte';
import { THEME_SIZE_DEFAULT, THEME_OPACITY_DEFAULT } from '$lib/theme';
let { data, form } = $props(); let { data, form } = $props();
let memberName = $state(data.memberName || ''); let memberName = $state(data.memberName || '');
let memberColor = $state(data.memberColor || '#6366f1'); let memberColor = $state(data.memberColor || '#6366f1');
let memberEmail = $state(data.email || ''); let memberEmail = $state(data.email || '');
let memberPattern = $state(data.pattern || '');
let memberSize = $state(Number(data.themeSize) > 0 ? Number(data.themeSize) : THEME_SIZE_DEFAULT);
let memberOpacity = $state(
Number(data.themeOpacity) > 0 ? Number(data.themeOpacity) : THEME_OPACITY_DEFAULT
);
const colors = [ const colors = [
'#6366f1', '#6366f1',
@@ -32,8 +40,10 @@
</CardGrid> </CardGrid>
{:else} {:else}
<CardGrid> <CardGrid>
<Card cols={2} title="Appearance"> <!-- One form for appearance + theme; display:contents keeps the
cards as grid items. -->
<form <form
style="display:contents"
method="POST" method="POST"
action="?/update" action="?/update"
use:enhance={() => { use:enhance={() => {
@@ -44,9 +54,19 @@
if (result.type === 'success' || result.type === 'failure') { if (result.type === 'success' || result.type === 'failure') {
await applyAction(result); await applyAction(result);
} }
// Refresh session.pattern so the app background updates, and drop
// the live draft — the saved value is authoritative from here on.
if (result.type === 'success') {
themeDraft.pattern = null;
themeDraft.color = null;
themeDraft.size = null;
themeDraft.opacity = null;
await invalidateAll();
}
}; };
}} }}
> >
<Card cols={2} title="Appearance">
<div style="margin-bottom:1rem"> <div style="margin-bottom:1rem">
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem"> <label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
Display Name Display Name
@@ -71,7 +91,10 @@
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor
? '3px solid #111827' ? '3px solid #111827'
: '2px solid transparent'};background:{c};cursor:pointer" : '2px solid transparent'};background:{c};cursor:pointer"
onclick={() => (memberColor = c)} onclick={() => {
memberColor = c;
themeDraft.color = c;
}}
></button> ></button>
{/each} {/each}
</div> </div>
@@ -102,9 +125,41 @@
<div class="form-actions"> <div class="form-actions">
<Button type="submit" variant="primary">Update preferences</Button> <Button type="submit" variant="primary">Update preferences</Button>
</div> </div>
</form>
</Card> </Card>
<Card cols={1} title="Theme">
<p class="theme-hint">
Pick a background pattern — it's coloured with your chosen colour. None keeps the plain
background.
</p>
<PatternPicker
value={themeDraft.pattern ?? memberPattern}
color={memberColor}
size={themeDraft.size ?? memberSize}
opacity={themeDraft.opacity ?? memberOpacity}
onchange={(v) => {
memberPattern = v;
// Live preview on the app background before saving.
themeDraft.pattern = v;
themeDraft.color = memberColor;
}}
onsize={(v) => {
memberSize = v;
// Live scale preview before saving.
themeDraft.size = v;
}}
onopacity={(v) => {
memberOpacity = v;
// Live fade preview before saving.
themeDraft.opacity = v;
}}
/>
<input type="hidden" name="pattern" value={themeDraft.pattern ?? memberPattern} />
<input type="hidden" name="themeSize" value={themeDraft.size ?? memberSize} />
<input type="hidden" name="themeOpacity" value={themeDraft.opacity ?? memberOpacity} />
</Card>
</form>
<Card cols={1} title="Account"> <Card cols={1} title="Account">
<p style="font-size:0.85rem;color:#6b7280"> <p style="font-size:0.85rem;color:#6b7280">
To change your device or sign in on a new device, use your invite link. To change your device or sign in on a new device, use your invite link.
@@ -122,4 +177,9 @@
padding-top: 1.25rem; padding-top: 1.25rem;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
} }
.theme-hint {
font-size: 0.85rem;
color: #6b7280;
margin: 0 0 0.75rem;
}
</style> </style>
@@ -7,6 +7,7 @@ import { issueAccess, createChild, inviteParent } from '$lib/server/member-otp';
import { sendParentInviteEmail } from '$lib/server/email'; import { sendParentInviteEmail } from '$lib/server/email';
import { slugify, handle, famUsername } from '@shared/slugify'; import { slugify, handle, famUsername } from '@shared/slugify';
import { applyAccessCode } from '$lib/server/access'; import { applyAccessCode } from '$lib/server/access';
import { getPlatformFlags } from '$lib/server/platform';
import { import {
createBillingPortalSession, createBillingPortalSession,
cancelSubscriptionAtPeriodEnd, cancelSubscriptionAtPeriodEnd,
@@ -18,7 +19,15 @@ function famIdOf(event: RequestEvent): string {
return event.locals.user.famId; return event.locals.user.famId;
} }
// In demo mode, settings is disabled for all users.
async function isDemoMode(): Promise<boolean> {
return (await getPlatformFlags()).demo;
}
export async function load(event: RequestEvent) { export async function load(event: RequestEvent) {
if (await isDemoMode()) {
return { members: [], fam: null, seasons: [], accessCode: null, subStatus: null, demoMode: true };
}
const famId = famIdOf(event); const famId = famIdOf(event);
const pb = pbUser(event); const pb = pbUser(event);
const [members, fam, seasons] = await Promise.all([ const [members, fam, seasons] = await Promise.all([
@@ -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
@@ -89,12 +89,15 @@ export const load: PageServerLoad = async (event) => {
pbAdmin.getList('bonus_templates', 'global = true') pbAdmin.getList('bonus_templates', 'global = true')
]); ]);
const platformFlags = await getPlatformFlags();
return { return {
authenticated: true, authenticated: true,
fams: famsWithStats, fams: famsWithStats,
codes: codeList, codes: codeList,
choreTemplates: choreTemplates as any[], choreTemplates: choreTemplates as any[],
bonusTemplates: bonusTemplates as any[], bonusTemplates: bonusTemplates as any[],
platformFlags,
totalFams: fams.length, totalFams: fams.length,
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0), totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0), totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
@@ -121,6 +124,7 @@ export const load: PageServerLoad = async (event) => {
codeCount: 0, codeCount: 0,
choreTemplates: [], choreTemplates: [],
bonusTemplates: [], bonusTemplates: [],
platformFlags: {},
loadError: e instanceof Error ? e.message : 'Failed to load platform data' loadError: e instanceof Error ? e.message : 'Failed to load platform data'
}; };
} }
+14
View File
@@ -188,6 +188,20 @@
</button> </button>
</form> </form>
</div> </div>
<div class="flag-row">
<span class="flag-name">demo</span>
<span class="flag-desc">Showboaters demo family with sample data for prospectus viewers</span>
<form method="POST" action="?/togglePlatformFlag" use:enhance>
<input type="hidden" name="flag" value="demo" />
<button
type="submit"
class="toggle-btn"
class:active={page.data.platformFlags?.demo}
>
{page.data.platformFlags?.demo ? 'ON' : 'OFF'}
</button>
</form>
</div>
<div class="flag-row"> <div class="flag-row">
<span class="flag-name">rewardsTargets</span> <span class="flag-name">rewardsTargets</span>
<span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span> <span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span>
@@ -1,7 +1,9 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit';
import { createPbClient } from '$lib/server/pocketbase'; import { createPbClient, pbAdmin } from '$lib/server/pocketbase';
import { createServices } from '$lib/server/services'; import { createServices } from '$lib/server/services';
import { sendClaimRequestEmail } from '$lib/server/email';
import { handleOf } from '@shared/slugify';
export async function POST(event: RequestEvent) { export async function POST(event: RequestEvent) {
const u = event.locals.user; const u = event.locals.user;
@@ -10,7 +12,35 @@ export async function POST(event: RequestEvent) {
const id = event.params.id!; const id = event.params.id!;
try { try {
const s = createServices(pb, { id: u.id, role: u.role }); const s = createServices(pb, { id: u.id, role: u.role });
return json(await s.rewards.claim(u.famId, id)); const record = await s.rewards.claim(u.famId, id);
// Tell the parent(s) a claim was requested — best-effort, so a mail
// outage can never fail the claim itself.
try {
const [parents, fam] = await Promise.all([
pbAdmin.getList('users', `famId = '${u.famId}' && role = 'parent'`),
pbAdmin.getOne('fams', u.famId)
]);
const label = record?.label || 'a reward';
const idx = label.indexOf(' – ');
const rewardName = idx === -1 ? label : label.slice(0, idx);
const rewardValue = idx === -1 ? '' : label.slice(idx + 3);
for (const parent of parents) {
if (!parent.email) continue;
await sendClaimRequestEmail({
to: parent.email,
parentName: parent.name || 'there',
childName: u.name || 'Someone',
rewardName,
rewardValue,
dashboardUrl: `${event.url.origin}/${fam?.slug || u.famId}/${handleOf(parent.username)}`
});
}
} catch {
// Best-effort only — the claim already succeeded.
}
return json(record);
} catch (e) { } catch (e) {
return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 }); return json({ error: e instanceof Error ? e.message : 'claim failed' }, { status: 400 });
} }
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.10.2" "version": "1.10.4"
} }