general ui patches

This commit is contained in:
JCEEE
2026-08-26 21:58:08 +01:00
parent fe4079fb56
commit f484673b84
11 changed files with 500 additions and 69 deletions
+49
View File
@@ -55,6 +55,48 @@ async function updateCollection(id: string, col: any): Promise<void> {
console.log(` ✓ Updated collection: ${col.name || id}`);
}
async function createRecord(collection: string, data: any): Promise<void> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
body: JSON.stringify(data),
});
if (!res.ok) {
const d = await res.json();
throw new Error(`Create record ${collection} failed: ${JSON.stringify(d)}`);
}
}
// Seed two default seasons (Holidays, Term time) per family so the family-admin
// season picker always has options. Idempotent by name.
async function ensureDefaultSeasons(): Promise<void> {
const famsCol = await getCollection("fams");
if (!famsCol) return;
const t = await auth();
const famsRes = await fetch(`${PB_ENDPOINT}/api/collections/fams/records?perPage=200`, {
headers: { Authorization: `Bearer ${t}` },
});
const fams = (await famsRes.json()).items || [];
const defaults = [
{ name: "Holidays", color: "#f59e0b", active: true },
{ name: "Term time", color: "#3b82f6", active: true },
];
for (const fam of fams) {
const sRes = await fetch(
`${PB_ENDPOINT}/api/collections/seasons/records?filter=(famId='${fam.id}')&fields=name&perPage=200`,
{ headers: { Authorization: `Bearer ${t}` } }
);
const have = new Set(((await sRes.json()).items || []).map((s: any) => s.name));
for (const d of defaults) {
if (!have.has(d.name)) {
await createRecord("seasons", { famId: fam.id, ...d });
console.log(` ✓ Seeded season ${d.name} for fam ${fam.id}`);
}
}
}
}
// Apply the custom fields + rules the app relies on to PB's native `users`
// auth collection (created automatically on first serve). Children live here as
// role='child'; username is a password-auth identity so the server can
@@ -332,6 +374,7 @@ export async function migrate(): Promise<void> {
await ensureAssignedChoreFields();
await ensureAccessCodes();
await ensurePlatform();
await ensureDefaultSeasons();
console.log("[migrate] Done");
}
@@ -353,6 +396,12 @@ async function ensureAssignedChoreFields(): Promise<void> {
typeField.values = [...typeField.values, "emoji"];
changed = true;
}
// Direct todos (created by a parent, not from a template) have no templateId.
const tplField = col.fields.find((f: any) => f.name === "templateId");
if (tplField && tplField.required) {
tplField.required = false;
changed = true;
}
if (changed) {
await updateCollection(col.id, { ...col, fields: [...col.fields] });
}
@@ -69,6 +69,7 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
value: Number(chore.value),
rewardType: 'cash',
status: 'unclaimed',
claimable: 'payday',
date
});
rewardId = reward.id;
+21 -3
View File
@@ -69,15 +69,33 @@ export const TEMPLATE_COLORS: TemplateColor[] = [
];
export function accentBg(color?: string): string {
if (!color) return 'transparent';
// Opaque pastel (desaturated toward white) so it doesn't blend with the
// container background behind assigned-chore cards. Uncolored items get a
// soft purple pastel (not grey) so they stay visible.
const base = color || '#8b5cf6';
const hex = base.replace('#', '');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const t = 0.78; // amount of white to mix in → pastel
const mix = (c: number) => Math.round(c + (255 - c) * t);
return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`;
}
function isLight(color: string): boolean {
const hex = color.replace('#', '');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.3)`;
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.8;
}
export function outlineColor(color?: string): string {
return color || '#e5e7eb';
// Uncolored or very light colors get a dark purple outline for contrast.
const fallback = '#4c1d95';
if (!color) return fallback;
if (isLight(color)) return fallback;
return color;
}
+1 -1
View File
@@ -147,7 +147,7 @@
<Sidebar {famName} session={data.session} {isParent} {role} />
<TopNav
{role}
seasons={famStore.seasons}
seasons={famStore.seasons.filter((s) => s.active !== false)}
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
>
<button class="chat-toggle" onclick={() => chatStore.toggle()} aria-label="Open chat">
@@ -292,6 +292,7 @@
return completions
.filter((c: Completion) => {
if (c.id.startsWith('optimistic-')) return false;
if (c.memberId !== memberId) return false;
const at = c.completedAt ? new Date(c.completedAt).getTime() : 0;
if (!at || at < cutoff) return false;
const chore = assigned.find((a) => a.id === c.assignedChoreId);
@@ -426,6 +427,8 @@
// ─── Threshold Goals (bonus configs the member is chasing) ───
let thresholdGoals = $derived.by(() => {
// Member's assigned chores (non-todo) — used to compute period potential.
const myChores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
return bonusConfigs
.filter((cfg) => {
if (cfg.status !== 'active') return false;
@@ -437,6 +440,11 @@
const per = cfg.period || 'weekly';
const pStart = periodStart(per, paydayDay, famTz);
const pEnd = periodEnd(per, pStart);
const periodDays =
Math.round(
(new Date(pEnd + 'T00:00:00').getTime() - new Date(pStart + 'T00:00:00').getTime()) /
86400000
) + 1;
const periodCompletions = completions.filter(
(c) =>
@@ -445,26 +453,66 @@
(c.date?.slice(0, 10) || c.date) <= pEnd
);
// Percent thresholds are "X% of the chores done this period" — the
// natural unit is chore COUNT. Absolute thresholds keep their own
// unit (count for type 'count', points for type 'threshold').
const isPercent = cfg.thresholdType === 'percent';
const isCount = cfg.type === 'count';
// Frequency-aware total potential (a daily chore = `periodDays`
// instances, a weekly chore = 1).
const totalPotential = myChores.reduce(
(sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1),
0
);
let current = 0;
if (cfg.type === 'threshold') {
if (isPercent) {
current = periodCompletions.length;
} else if (isCount) {
current = periodCompletions.length;
} else {
current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
current = periodCompletions.length;
}
const configured = Number(cfg.criteriaValue) || 0;
// Percent thresholds resolve to an absolute count against the
// period's potential (0 when no chores are assigned).
const target = isPercent
? Math.round((configured / 100) * totalPotential)
: configured;
// Display percent = completed ÷ period potential (not ÷ threshold),
// so a 1-of-7 week reads 14%, not 25%.
const ratio = totalPotential > 0 ? current / totalPotential : 0;
const pct = isPercent
? totalPotential > 0
? Math.min(100, Math.round(ratio * 100))
: 0
: target > 0
? Math.min(100, Math.round((current / target) * 100))
: 0;
const existingReward = rewards.find(
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
);
const criteria = Number(cfg.criteriaValue) || 0;
const achieved = existingReward ? true : criteria > 0 && current >= criteria;
const achieved = existingReward
? true
: isPercent
? totalPotential > 0 && ratio * 100 >= configured
: target > 0 && current >= target;
return {
config: cfg,
current,
criteriaValue: criteria,
criteriaValue: configured,
target,
totalPotential,
isPercent,
pct,
achieved,
rewardStatus: existingReward?.status || null,
periodStart: pStart,
@@ -1145,10 +1193,7 @@ const res = await fetch('/api/members', {
{#if thresholdGoals.length > 0}
<div class="goals">
{#each thresholdGoals as goal}
{@const pct =
goal.criteriaValue > 0
? Math.min(100, Math.round((goal.current / goal.criteriaValue) * 100))
: 0}
{@const pct = goal.pct}
<div class="goal-card" class:goal-achieved={goal.achieved}>
<div class="goal-head">
<span class="goal-name">{goal.config.name}</span>
@@ -1166,9 +1211,17 @@ const res = await fetch('/api/members', {
<div class="goal-bar-fill" style="width:{pct}%"></div>
</div>
<div class="goal-foot">
<span class="goal-progress"
>{Math.min(goal.current, goal.criteriaValue)} / {goal.criteriaValue}</span
>
{#if goal.isPercent && goal.totalPotential === 0}
<span class="goal-progress muted">no chores set</span>
{:else if goal.isPercent}
<span class="goal-progress"
>{Math.min(goal.current, goal.totalPotential)} / {goal.totalPotential}</span
>
{:else}
<span class="goal-progress"
>{Math.min(goal.current, goal.target)} / {goal.target}</span
>
{/if}
{#if goal.achieved}
<span class="goal-badge">✅ earned</span>
{:else}
@@ -123,11 +123,20 @@ export const actions = {
const type = fd.get('type');
const value = fd.get('value');
const customName = fd.get('customName');
const descriptionRaw = fd.get('description');
const colorRaw = fd.get('color');
const iconRaw = fd.get('icon');
const seasonIdsRaw = fd.get('seasonIds') as string;
const fieldStr = (v: FormDataEntryValue | null) =>
v === null ? undefined : (v as string);
if (frequency) data.frequency = frequency;
if (type) data.type = type;
if (value) data.value = parseFloat(value as string) || 0;
data.customName = (customName as string) || undefined;
// Preserve empty strings so fields can be cleared (e.g. colour → none).
data.customName = fieldStr(customName);
data.description = fieldStr(descriptionRaw);
data.color = fieldStr(colorRaw);
data.icon = fieldStr(iconRaw);
if (seasonIdsRaw || seasonIdsRaw === '') {
data.seasonIds = seasonIdsRaw ? seasonIdsRaw.split(',').filter(Boolean) : [];
}
@@ -5,6 +5,7 @@
import { ViewHeader, CardGrid, Card } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { formatHumanDate } from '$lib/format';
import { TEMPLATE_COLORS, ICON_NAMES, accentBg, outlineColor } from '$lib/templateIcons';
import type { ChoreTemplate, AssignedChore, Member, Season, Completion } from '$lib/types';
// ── Chevron SVG icons ──
@@ -25,6 +26,10 @@
// Assign-from-template modal (platform templates are copied into the family
// as assigned chores; the fam admin amends before saving).
let assignModal = $state<{ t: ChoreTemplate; memberId: string } | null>(null);
let assignName = $state('');
let assignDescription = $state('');
let assignColor = $state('');
let assignIcon = $state('');
let assignFreq = $state('daily');
let assignType = $state('points');
let assignValue = $state(10);
@@ -50,6 +55,9 @@
let editType = $state('points');
let editValue = $state(10);
let editCustomName = $state('');
let editDescription = $state('');
let editColor = $state('');
let editIcon = $state('');
let editEmoji = $state('🎉');
let editTodoCompleteBy = $state('');
let editTodoStartDate = $state('');
@@ -73,6 +81,8 @@
);
let seasons = $state(famStore.initialized ? famStore.seasons : data.seasons || []);
// Only active seasons are selectable for assignment / filtering.
let activeSeasons = $derived((seasons || []).filter((s) => s.active !== false));
let completions = $state((data.completions as Completion[]) || []);
let filteredAssigned = $derived(
@@ -104,6 +114,10 @@
return seasons.find((s) => s.id === id)?.name || id;
}
function seasonColor(id: string): string {
return seasons.find((s) => s.id === id)?.color || '#9ca3af';
}
function seasonNames(a: AssignedChore): string {
return (a.seasonIds || []).map((id) => seasonName(id)).join(', ') || 'Global';
}
@@ -133,6 +147,10 @@
const t = templates.find((x) => x.id === tid);
if (!t) return;
assignModal = { t, memberId };
assignName = t.name || '';
assignDescription = t.description || '';
assignColor = t.color || '';
assignIcon = t.icon || '';
assignFreq = t.defaultFrequency;
assignType = t.defaultType;
assignValue = t.defaultValue;
@@ -146,6 +164,10 @@
const body: Record<string, unknown> = {
memberId: assignModal.memberId,
templateId: assignModal.t.id,
customName: assignName,
description: assignDescription,
color: assignColor,
icon: assignIcon,
frequency: assignFreq,
type: assignType,
value: assignValue
@@ -227,6 +249,9 @@
editType = a.type;
editValue = a.value;
editCustomName = a.customName || '';
editDescription = a.description || '';
editColor = a.color || '';
editIcon = a.icon || '';
editEmoji = a.emoji || '🎉';
editSeasonIds = a.seasonIds ? [...a.seasonIds] : [];
editTodoCompleteBy = a.completeBy || '';
@@ -239,7 +264,14 @@
editingAssignment = null;
}
function enhanceUpdateAssigned() {
function enhanceUpdateAssigned(e: any) {
const data = e?.data;
if (data && editingAssignment && !editingAssignment.isTodo) {
data.set('seasonIds', editSeasonIds.join(','));
data.set('color', editColor);
data.set('icon', editIcon);
data.set('description', editDescription);
}
return async ({ result }: any) => {
if (result.type === 'success' && editingAssignment) {
const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id);
@@ -283,7 +315,7 @@
<label>Season:</label>
<select bind:value={seasonFilter}>
<option value="all">All Chores</option>
{#each seasons as s}
{#each activeSeasons as s}
<option value={s.id}>{s.name}</option>
{/each}
</select>
@@ -291,9 +323,14 @@
</div>
<div class="template-list">
{#each templates as t}
<div class="card template" draggable="true" ondragstart={(e) => handleDragStart(e, t.id)} style="border-left-color:{t.color}">
<div
class="card template"
draggable="true"
ondragstart={(e) => handleDragStart(e, t.id)}
style="border-left-color:{outlineColor(t.color)}; background:{accentBg(t.color)}"
>
<div class="card-body">
<TemplateIcon name={t.icon} size={18} color={t.color} />
<TemplateIcon name={t.icon} size={18} color={outlineColor(t.color)} />
<strong>{t.name}</strong>
<span class="badge">{t.defaultFrequency}</span>
<span class="badge type">{t.defaultType}</span>
@@ -407,13 +444,25 @@
<div class="accordion-body">
{#each assignedForMember(m.id) as a}
{@const tName = templateName(a.templateId)}
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
<div
class="card assigned"
onclick={() => openEdit(a)}
role="button"
tabindex="0"
style="border-left-color:{outlineColor(a.color)}; background:{accentBg(a.color)}"
>
<div class="card-body">
<TemplateIcon name={a.icon} size={16} color={outlineColor(a.color)} />
<strong>{a.customName || tName}</strong>
<span class="badge">{a.frequency}</span>
<span class="badge type">{a.type}</span>
{#if seasonFilter !== 'all' && isGlobalChore(a)}
<span class="badge season">Add to</span>
{#each a.seasonIds || [] as sid}
<span class="badge season" style="border-color:{seasonColor(sid)}; color:{seasonColor(sid)}"
>{seasonName(sid)}</span
>
{/each}
{#if (a.seasonIds || []).length === 0}
<span class="badge season muted">All year</span>
{/if}
</div>
<div class="card-value">
@@ -451,15 +500,67 @@
</Card>
</CardGrid>
<!-- Season pill selector (reused by assign + edit modals) -->
{#snippet seasonPills(list: Season[], selected: string[], set: (v: string[]) => void)}
<div class="season-pills">
<button
type="button"
class="season-pill"
class:selected={selected.length === 0}
style="--pill:#9ca3af"
onclick={() => set([])}>All year</button
>
{#each list as s}
<button
type="button"
class="season-pill"
class:selected={selected[0] === s.id}
style="--pill:{s.color}"
onclick={() => set([s.id])}>{s.name}</button
>
{/each}
</div>
{/snippet}
<!-- Assign from Template Modal -->
{#if assignModal}
<div class="overlay" onclick={() => (assignModal = null)} role="presentation">
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) assignModal = null; }} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Assign: {assignModal.t.name}</h3>
<p class="hint">
Copies this platform template into {members.find((m) => m.id === assignModal?.memberId)?.name}'s
chores. Amend, then Assign.
</p>
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
<label
>Description <input bind:value={assignDescription} placeholder="Optional description" /></label
>
<div class="color-field">
<span class="field-label">Colour</span>
<div class="color-swatches">
{#each TEMPLATE_COLORS as c}
<button
type="button"
class="swatch"
class:selected={assignColor === c.value}
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
'transparent'
? '#e5e7eb'
: c.value}"
title={c.label}
onclick={() => (assignColor = c.value)}></button>
{/each}
</div>
</div>
<label class="icon-row"
><span class="icon-preview"
><TemplateIcon name={assignIcon} size={22} color={outlineColor(assignColor)} /></span
>
<select bind:value={assignIcon}>
<option value="">None</option>
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
</select>
</label>
<label>
Frequency
<select bind:value={assignFreq}>
@@ -478,6 +579,10 @@
Value
<input type="number" step="any" bind:value={assignValue} />
</label>
<div class="season-field">
<span class="field-label">Seasons</span>
<seasonPills activeSeasons selected={assignSeasonIds} set={(v: string[]) => (assignSeasonIds = v)} />
</div>
<div class="modal-actions">
<button type="button" onclick={() => (assignModal = null)}>Cancel</button>
<button type="button" onclick={assignChore}>Assign</button>
@@ -488,7 +593,7 @@
<!-- Edit Assignment Modal -->
{#if showEditModal && editingAssignment}
<div class="overlay" onclick={closeEdit} role="presentation">
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) closeEdit(); }} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>
{#if editingAssignment.isTodo}
@@ -504,6 +609,10 @@
Name
<input name="customName" bind:value={editCustomName} placeholder="Name" />
</label>
<label
>Description
<input name="description" bind:value={editDescription} placeholder="Optional description" /></label
>
{#if editingAssignment.isTodo}
<label>
@@ -561,6 +670,33 @@
</label>
{:else}
<input name="seasonIds" type="hidden" value={editSeasonIds.join(',')} />
<div class="color-field">
<span class="field-label">Colour</span>
<div class="color-swatches">
{#each TEMPLATE_COLORS as c}
<button
type="button"
class="swatch"
class:selected={editColor === c.value}
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
'transparent'
? '#e5e7eb'
: c.value}"
title={c.label}
onclick={() => (editColor = c.value)}></button>
{/each}
</div>
<input type="hidden" name="color" value={editColor} />
</div>
<label
><span class="icon-preview"
><TemplateIcon name={editIcon} size={22} color={outlineColor(editColor)} /></span
>
<select name="icon" bind:value={editIcon}>
<option value="">None</option>
{#each ICON_NAMES as n}<option value={n}>{n}</option>{/each}
</select>
</label>
<label>
Frequency
<select name="frequency" bind:value={editFreq}>
@@ -579,36 +715,10 @@
Value
<input name="value" type="number" step="any" bind:value={editValue} required />
</label>
<label>
Seasons
<div class="season-checklist">
{#each seasons as s}
<label class="check-item">
<input
type="checkbox"
checked={editSeasonIds.includes(s.id)}
onchange={() => {
if (editSeasonIds.includes(s.id)) {
editSeasonIds = editSeasonIds.filter((x) => x !== s.id);
} else {
editSeasonIds = [...editSeasonIds, s.id];
}
}}
/>
<span class="dot" style="background:{s.color}"></span>
{s.name}
</label>
{/each}
<label class="check-item">
<input
type="checkbox"
checked={editSeasonIds.length === 0}
onchange={() => (editSeasonIds = [])}
/>
Global (no season)
</label>
</div>
</label>
<div class="season-field">
<span class="field-label">Seasons</span>
<seasonPills activeSeasons selected={editSeasonIds} set={(v: string[]) => (editSeasonIds = v)} />
</div>
{/if}
<div class="modal-actions">
<button type="button" onclick={closeEdit}>Cancel</button>
@@ -621,7 +731,7 @@
<!-- Add Todo Modal -->
{#if showTodoModal && todoMemberId}
<div class="overlay" onclick={() => (showTodoModal = false)} role="presentation">
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) showTodoModal = false; }} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Add Todo for {members.find((m) => m.id === todoMemberId)?.name || 'Member'}</h3>
<form
@@ -941,9 +1051,14 @@
background: #fff;
}
.badge.season {
background: #e0f2fe;
background: transparent;
border: 1px solid #0369a1;
color: #0369a1;
}
.badge.season.muted {
color: #9ca3af;
border-color: #e5e7eb;
}
.card-meta {
font-size: 0.7rem;
color: #9ca3af;
@@ -971,6 +1086,76 @@
margin: 0;
}
.color-field,
.season-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.75rem;
}
.field-label {
font-size: 0.85rem;
font-weight: 600;
color: #6b7280;
}
.modal label.icon-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.modal label.icon-row select {
flex: 1;
}
.icon-preview {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
}
.color-swatches {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid #e5e7eb;
cursor: pointer;
padding: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.swatch.selected {
outline: 2px solid #111827;
outline-offset: 1px;
}
.season-pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.season-pill {
padding: 0.3rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--pill, #e5e7eb);
background: transparent;
color: #374151;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.12s ease;
}
.season-pill.selected {
background: var(--pill, #8b5cf6);
color: #fff;
border-color: var(--pill, #8b5cf6);
}
.template {
cursor: grab;
border-left: 4px solid #8b5cf6;
@@ -138,6 +138,18 @@ export const actions = {
return { deletedChoreIds: deletedIds };
},
toggleSeason: async (event: RequestEvent) => {
const famId = famIdOf(event);
const fd = await event.request.formData();
const id = fd.get('id') as string;
const active = fd.get('active') === '1';
if (!id) return { error: 'Season ID required' };
await pbUser(event)
.collection('seasons')
.update(id, { active });
return { ok: true };
},
// Compute endpoints — in-process.
completeWeek: async (event: RequestEvent) => {
const famId = famIdOf(event);
@@ -86,6 +86,28 @@
let deletingSeason = $state<any>(null);
let issued = $state<{ otp: string; joinUrl: string; name: string } | null>(null);
// Optimistic season active toggle: flip immediately in the shared famStore
// (which drives both this list and the TopNav), then persist via the
// ?/toggleSeason action and revert on failure.
async function toggleSeasonActive(s: any, next: boolean) {
const i = famStore.seasons.findIndex((x) => x.id === s.id);
const prev = i !== -1 ? famStore.seasons[i].active !== false : true;
if (i !== -1) famStore.seasons[i] = { ...famStore.seasons[i], active: next };
try {
const fd = new FormData();
fd.set('id', s.id);
fd.set('active', next ? '1' : '0');
const res = await fetch('?/toggleSeason', {
method: 'POST',
body: fd,
headers: { 'x-sveltekit-action': 'true' }
});
if (!res.ok) throw new Error('request failed');
} catch {
if (i !== -1) famStore.seasons[i] = { ...famStore.seasons[i], active: prev };
}
}
let invitePath = $derived(issued ? `${issued.joinUrl}?code=${issued.otp}` : '');
let inviteUrl = $derived(issued ? `${page.url.origin}${invitePath}` : '');
@@ -210,7 +232,7 @@
</p>
</Card>
<Card title="Seasons" cols={1}>
<p class="hint">Group chores into seasons. Toggle seasons on/off from the top nav.</p>
<p class="hint">Group chores into seasons. Tick a season on to make it available for assignment; untick to disable it.</p>
<form method="POST" action="?/createSeason" use:enhance class="season-form">
<label class="field-label" for="season-name">New season</label>
<input id="season-name" name="name" placeholder="Season name" required />
@@ -228,12 +250,34 @@
</form>
<ul>
{#each data.seasons as s}
{#each (famStore.initialized ? famStore.seasons : data.seasons) as s}
<li>
<span class="dot" style="background:{s.color}"></span>
{s.name}
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button
<span class="season-name">{s.name}</span>
<button
type="button"
class="season-check"
class:on={s.active !== false}
role="switch"
aria-checked={s.active !== false}
title={s.active !== false ? 'Active — click to disable' : 'Disabled — click to enable'}
onclick={() => toggleSeasonActive(s, !(s.active !== false))}
>
{#if s.active !== false}
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><polyline points="20 6 9 17 4 12" /></svg
>
{/if}
</button>
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
</li>
{/each}
</ul>
@@ -698,6 +742,33 @@
li:last-child {
border-bottom: none;
}
.season-name {
flex: 1;
min-width: 80px;
}
.season-check {
width: 26px;
height: 26px;
border-radius: 8px;
border: 2px solid #d1d5db;
background: #fff;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition:
background 0.12s ease,
border-color 0.12s ease;
}
.season-check.on {
background: #22c55e;
border-color: #22c55e;
}
.season-check svg {
display: block;
}
.dot {
display: inline-block;
width: 10px;
+37 -4
View File
@@ -4,7 +4,7 @@
import { page } from '$app/state';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import TemplateIcon from '$lib/components/TemplateIcon.svelte';
import { ICON_NAMES } from '$lib/templateIcons';
import { ICON_NAMES, TEMPLATE_COLORS } from '$lib/templateIcons';
import Footer from '$lib/components/Footer.svelte';
let { data, form } = $props();
@@ -400,10 +400,24 @@
</select>
</label>
<span class="icon-preview"><TemplateIcon name={tplVals.icon} size={22} color={tplVals.color} /></span>
<label>
<div class="color-field">
Colour
<input type="color" name="color" bind:value={tplVals.color} />
</label>
<div class="color-swatches">
{#each TEMPLATE_COLORS as c}
<button
type="button"
class="swatch"
class:selected={tplVals.color === c.value}
style="background:{c.value || 'transparent'}; border-color:{c.chromatic ===
'transparent'
? '#e5e7eb'
: c.value}"
title={c.label}
onclick={() => (tplVals.color = c.value)}></button>
{/each}
</div>
<input type="hidden" name="color" value={tplVals.color} />
</div>
</div>
{#if tplType === 'chore'}
@@ -936,6 +950,25 @@
.tpl-actions { display: flex; gap: 0.75rem; }
.tpl-new { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.row { display: flex; align-items: flex-end; gap: 0.75rem; flex-wrap: wrap; }
.color-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 600;
color: #6b7280;
}
.color-swatches { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid #e5e7eb;
cursor: pointer;
padding: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.swatch.selected { outline: 2px solid #111827; outline-offset: 1px; }
.icon-preview {
display: inline-flex;
align-items: center;
+2 -2
View File
@@ -319,7 +319,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
number("value", true),
select("rewardType", ["cash", "prize", "points"], true),
select("status", ["unclaimed", "requested", "claimed"], true),
select("claimable", ["immediate", "payday"], true),
select("claimable", ["immediate", "payday"], false),
text("settleDate"),
date("claimedAt"),
date("requestedAt"),
@@ -336,7 +336,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
col("assigned_chores", [
rel("famId", ids.fams, true),
rel("memberId", ids.users, true),
rel("templateId", ids.chore_templates, true),
rel("templateId", ids.chore_templates),
select("frequency", ["daily", "weekly"], true),
select("type", ["points", "money", "emoji"], true),
number("value", true),