add more ux and ui patches with extra login helper

This commit is contained in:
JCEEE
2026-08-27 14:13:14 +01:00
parent 84b3cd48f4
commit 9f335db7b2
16 changed files with 643 additions and 150 deletions
+185 -56
View File
@@ -6,6 +6,7 @@
import { memberApi } from '$lib/client/api';
import { formatDDMMYY, formatHumanDate } from '$lib/format';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import { checkCircleIcon, revokeIcon } from '$lib/components/icons';
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
import { handleOf } from '@shared/slugify';
import {
@@ -64,10 +65,6 @@
};
});
function totalChoresFor(memberId: string): number {
return parentAssigned.filter((a: any) => a.memberId === memberId).length;
}
function todayCompletionsFor(memberId: string) {
return parentCompletions.filter(
(c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today
@@ -332,10 +329,17 @@
);
let weeklyTotal = $derived.by(() => {
const dailyChores = memberChores.filter((a) => a.frequency === 'daily');
const weeklyChores = memberChores.filter((a) => a.frequency === 'weekly');
const activeTodos = memberTodos.filter((t) => t.type !== 'emoji').length;
return dailyChores.length * 7 + weeklyChores.length + activeTodos;
// Must match the pocket-money % threshold definition: all assigned
// non-todo chores for the member (no season filter), weighted by the
// number of days in the displayed period — so the hero total agrees
// with the Goals progress instead of showing a different denominator.
const chores = assigned.filter((a) => a.memberId === memberId && !a.isTodo);
const periodDays =
Math.round(
(new Date(weekEnd + 'T00:00:00').getTime() - new Date(weekStart + 'T00:00:00').getTime()) /
86400000
) + 1;
return chores.reduce((sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1), 0);
});
let weekCompletions = $derived(
@@ -349,6 +353,17 @@
let weeklyDone = $derived(weekCompletions.length);
let weeklyPct = $derived(weeklyTotal > 0 ? Math.round((weeklyDone / weeklyTotal) * 100) : 0);
// Pocket-money target as a % of chores, for the layered target bar under the
// hero progress. Only meaningful for a percent threshold (points targets
// can't be expressed as a fraction of chores).
let pmTargetPct = $derived.by(() => {
const pm = bonusConfigs.find(
(c) => c.isPocketMoney && c.status === 'active' && (!c.memberId || c.memberId === memberId)
);
if (!pm || pm.thresholdType !== 'percent') return null;
return Math.min(100, Math.max(0, Number(pm.criteriaValue) || 0));
});
let weekRewards = $derived(
rewards.filter((r) => r.memberId === memberId && (r.date?.slice(0, 10) || r.date) >= weekStart)
);
@@ -505,10 +520,15 @@
? totalPotential > 0 && ratio * 100 >= configured
: target > 0 && current >= target;
return {
config: cfg,
current,
criteriaValue: configured,
const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts';
const targetLabel = `${configured} ${unit}`;
const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100;
return {
config: cfg,
current,
criteriaValue: configured,
targetPct,
targetLabel,
target,
totalPotential,
isPercent,
@@ -705,13 +725,11 @@
<div class="kanban-scroll">
<div class="kanban-inner">
<CardGrid>
<CardGrid stackTablet>
<Card cols={1} title="Members">
{#each parentMembers as m}
{@const total = totalChoresFor(m.id)}
{@const todays = todayCompletionsFor(m.id)}
{@const done = todays.length}
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
{@const s = memberInSummary(m.id)}
{@const pm = parentBonusConfigs.find(
(c: any) => c.isPocketMoney && c.memberId === m.id
@@ -722,7 +740,7 @@
<span class="member-name">{m.name}</span>
{#if !(pm && pm.rewardValue)}
<Button
href="/{famSlug}/{username}/bonuses?s=pocketmoney"
href="/{famSlug}/{username}/rewards?s=pocketmoney"
variant="secondary"
size="sm">Pocket money</Button
>
@@ -742,18 +760,12 @@
<span class="unset">Pocket money: amount not set</span>
{/if}
</div>
<div class="progress-row">
<span class="label">Today:</span>
<div class="bar-wrap">
<div class="bar-fill" style="width:{pct}%"></div>
</div>
<span class="count">{done}/{total}</span>
</div>
{#if done > 0}
<ul class="done-list">
{#each todays as c}
<li>
✅ {choreNameFor(c.assignedChoreId)}
{#each todays as c, i}
<li class="done-item" class:odd={i % 2 === 1}>
<span class="done-icon">{@html checkCircleIcon}</span>
<span class="done-name">{choreNameFor(c.assignedChoreId)}</span>
<form
method="POST"
action="?/revoke"
@@ -763,7 +775,7 @@
class="revoke-form"
>
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
<button type="submit" class="revoke-btn" title="Revoke">{@html revokeIcon}</button>
</form>
</li>
{/each}
@@ -1145,8 +1157,18 @@ const res = await fetch('/api/members', {
<div class="hero-progress">
<span class="hero-pct">{weeklyTotal > 0 ? weeklyPct : 0}%</span>
<div class="bar-wrap hero-bar">
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div>
<div class="hero-bars">
<div class="bar-wrap hero-bar">
<div class="bar-fill hero-fill" style="width:{weeklyPct}%"></div>
</div>
{#if pmTargetPct != null}
<div class="hero-target">
<div class="bar-wrap hero-target-bar">
<div class="bar-fill hero-target-fill" style="width:{pmTargetPct}%"></div>
</div>
<span class="hero-target-lbl">🎯 {pmTargetPct}%</span>
</div>
{/if}
</div>
<span class="hero-count">
{weeklyTotal > 0 ? `${weeklyDone}/${weeklyTotal} done` : 'no chores yet'}
@@ -1209,6 +1231,9 @@ const res = await fetch('/api/members', {
</div>
<div class="goal-bar-wrap">
<div class="goal-bar-fill" style="width:{pct}%"></div>
{#if goal.targetPct < 100}
<div class="goal-target" style="left:{goal.targetPct}%"></div>
{/if}
</div>
<div class="goal-foot">
{#if goal.isPercent && goal.totalPotential === 0}
@@ -1222,11 +1247,12 @@ const res = await fetch('/api/members', {
>{Math.min(goal.current, goal.target)} / {goal.target}</span
>
{/if}
{#if goal.achieved}
<span class="goal-badge">✅ earned</span>
{:else}
<span class="goal-pct">{pct}%</span>
{/if}
{#if goal.achieved}
<span class="goal-badge">✅ earned</span>
{:else}
<span class="goal-target-pill">🎯 {goal.targetLabel}</span>
<span class="goal-pct">{pct}%</span>
{/if}
</div>
</div>
{/each}
@@ -1293,19 +1319,24 @@ const res = await fetch('/api/members', {
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
{@const urgency = todoUrgency(todo)}
{#if todo.type === 'emoji'}
<div
class="chore todo-chore todo-display"
class:todo-blue={urgency === 'blue'}
class:todo-green={urgency === 'green'}
class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'}
>
<span class="checkbox">📋</span>
<div
class="chore todo-chore todo-display"
class:todo-blue={urgency === 'blue'}
class:todo-green={urgency === 'green'}
class:todo-orange={urgency === 'orange'}
class:todo-red={urgency === 'red'}
class:todo-black={urgency === 'black'}
>
<span class="checkbox">📋</span>
<span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span>
<span class="todo-label">todo</span>
<span class="chore-value">{todo.emoji || '🎯'}</span>
</div>
{#if todo.completeBy}
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
{/if}
</span>
<span class="todo-label">todo</span>
<span class="chore-value">{todo.emoji || '🎯'}</span>
</div>
{:else}
<button
class="chore todo-chore"
@@ -1318,7 +1349,12 @@ const res = await fetch('/api/members', {
disabled={!!togglingIds}
>
<span class="checkbox">📋</span>
<span class="chore-name">{todo.customName || 'Todo'}</span>
<span class="todo-main">
<span class="chore-name">{todo.customName || 'Todo'}</span>
{#if todo.completeBy}
<span class="todo-due">due {formatHumanDate(todo.completeBy)}</span>
{/if}
</span>
<span class="todo-label">todo</span>
<span class="chore-value">
{todo.type === 'money'
@@ -1530,18 +1566,39 @@ const res = await fetch('/api/members', {
.done-list {
list-style: none;
padding: 0;
margin: 0;
margin: 0.4rem 0 0;
display: flex;
flex-direction: column;
gap: 0;
}
.done-list li {
font-size: 0.75rem;
padding: 0.05rem 0;
color: #059669;
.done-item {
display: flex;
align-items: center;
gap: 0.25rem;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
font-size: 0.8rem;
color: #059669;
}
.done-item.odd {
background: #f3f4f6;
}
.done-icon {
display: inline-flex;
color: #22c55e;
flex-shrink: 0;
}
.done-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.revoke-form {
display: inline;
margin-left: auto;
display: inline-flex;
flex-shrink: 0;
}
.revoke-btn {
background: none;
@@ -1792,6 +1849,41 @@ const res = await fetch('/api/members', {
.hero-fill {
background: #fff;
}
.hero-bars {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
justify-content: center;
min-width: 0;
}
.hero-bars .hero-bar {
flex: none;
width: 100%;
}
.hero-target {
display: flex;
align-items: center;
gap: 0.45rem;
}
.hero-target-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.22);
}
.hero-target-fill {
height: 100%;
background: #fde047;
border-radius: 4px;
transition: width 0.3s ease;
}
.hero-target-lbl {
font-size: 0.7rem;
font-weight: 700;
color: #fde047;
white-space: nowrap;
flex-shrink: 0;
}
.hero-count {
font-size: 0.8rem;
font-weight: 700;
@@ -1944,17 +2036,40 @@ const res = await fetch('/api/members', {
color: #059669;
}
.goal-bar-wrap {
position: relative;
height: 8px;
background: #f3f4f6;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.35rem;
overflow: visible;
margin: 0.35rem 0 0.5rem;
}
.goal-bar-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 4px;
transition: width 0.3s ease;
max-width: 100%;
}
.goal-target {
position: absolute;
top: -4px;
bottom: -4px;
width: 3px;
background: #ef4444;
border-radius: 2px;
transform: translateX(-50%);
z-index: 2;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.8);
}
.goal-target-pill {
font-size: 0.72rem;
font-weight: 700;
color: #ef4444;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 999px;
padding: 1px 8px;
white-space: nowrap;
}
.goal-achieved .goal-bar-fill {
background: linear-gradient(90deg, #10b981, #34d399);
@@ -2104,6 +2219,20 @@ const res = await fetch('/api/members', {
padding: 1px 5px;
flex-shrink: 0;
}
.todo-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
gap: 1px;
}
.todo-due {
font-size: 0.68rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.todo-display {
cursor: default;
opacity: 0.85;