general ui patches
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user