enable kids todos
This commit is contained in:
@@ -23,6 +23,9 @@ export const memberApi = {
|
|||||||
async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
|
async toggleCompletion(famId: string, assignedChoreId: string, date: string, completedAt?: string) {
|
||||||
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt });
|
return memberFetch('POST', '/api/completions/toggle', { assignedChoreId, date, completedAt });
|
||||||
},
|
},
|
||||||
|
async createTodo(name: string) {
|
||||||
|
return memberFetch<{ record: unknown }>('POST', '/api/todos', { name });
|
||||||
|
},
|
||||||
async claimReward(famId: string, rewardId: string) {
|
async claimReward(famId: string, rewardId: string) {
|
||||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -321,7 +321,12 @@ class FamStore {
|
|||||||
// and from PB subscribe SSE for multi-user realtime.
|
// and from PB subscribe SSE for multi-user realtime.
|
||||||
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
||||||
const apply = <T extends { id: string }>(list: T[]): T[] => {
|
const apply = <T extends { id: string }>(list: T[]): T[] => {
|
||||||
if (action === 'create') return [record, ...list];
|
// A create for an id we already hold (optimistic add + SSE echo) is
|
||||||
|
// an update, not a second row.
|
||||||
|
if (action === 'create')
|
||||||
|
return list.some((x) => x.id === record.id)
|
||||||
|
? list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
|
||||||
|
: [record, ...list];
|
||||||
if (action === 'update')
|
if (action === 'update')
|
||||||
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
|
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
|
||||||
if (action === 'delete') return list.filter((x) => x.id !== record.id);
|
if (action === 'delete') return list.filter((x) => x.id !== record.id);
|
||||||
|
|||||||
@@ -694,7 +694,7 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-top: 4px solid #6366f1;
|
border-top: 4px solid #6366f1;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem;
|
padding: 0.9rem;
|
||||||
background: #fafbff;
|
background: #fafbff;
|
||||||
}
|
}
|
||||||
.col-member {
|
.col-member {
|
||||||
@@ -709,6 +709,7 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -741,8 +742,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.6rem;
|
gap: 0.7rem;
|
||||||
margin: 0.9rem 0 0.7rem;
|
margin: 1.1rem 0 1.2rem;
|
||||||
}
|
}
|
||||||
.donut-wrap {
|
.donut-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -780,19 +781,20 @@
|
|||||||
.member-stats {
|
.member-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.45rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.member-stats.compact .mstat {
|
.member-stats.compact .mstat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0.4rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.76rem;
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
color: #374151;
|
color: #374151;
|
||||||
background: rgba(255, 255, 255, 0.65);
|
background: rgba(255, 255, 255, 0.65);
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 0.28rem 0.5rem;
|
padding: 0.45rem 0.65rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,29 @@
|
|||||||
let todayChild = $derived(todayInTz(famTz));
|
let todayChild = $derived(todayInTz(famTz));
|
||||||
let togglingIds = $state<Set<string>>(new Set());
|
let togglingIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// ─── Kid-created todos ───
|
||||||
|
let showTodoModal = $state(false);
|
||||||
|
let newTodoName = $state('');
|
||||||
|
let todoSaving = $state(false);
|
||||||
|
let todoError = $state('');
|
||||||
|
async function submitTodo() {
|
||||||
|
const name = newTodoName.trim();
|
||||||
|
if (!name || todoSaving) return;
|
||||||
|
todoSaving = true;
|
||||||
|
todoError = '';
|
||||||
|
try {
|
||||||
|
const res = await memberApi.createTodo(name);
|
||||||
|
// Instant UI — the SSE echo dedupes on id via applyRecord.
|
||||||
|
famStore.applyRecord('assigned_chores', res.record, 'create');
|
||||||
|
newTodoName = '';
|
||||||
|
showTodoModal = false;
|
||||||
|
} catch (e) {
|
||||||
|
todoError = e instanceof Error ? e.message : 'Could not add todo';
|
||||||
|
} finally {
|
||||||
|
todoSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Payday countdown (child view) ───
|
// ─── Payday countdown (child view) ───
|
||||||
let nowMs = $state(Date.now());
|
let nowMs = $state(Date.now());
|
||||||
let eowFired = $state(false);
|
let eowFired = $state(false);
|
||||||
@@ -1765,6 +1788,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
<button class="add-todo-cta" onclick={() => (showTodoModal = true)}>
|
||||||
|
<span aria-hidden="true">+</span> Add todo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="column col-done">
|
<div class="column col-done">
|
||||||
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
|
<h2>{@html checkCircleIcon} Done ({completedToday.length})</h2>
|
||||||
@@ -1799,6 +1825,45 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- WALLET / CLAIMS -->
|
<!-- WALLET / CLAIMS -->
|
||||||
|
{#if showTodoModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="modal-overlay"
|
||||||
|
onclick={() => !todoSaving && (showTodoModal = false)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && !todoSaving && (showTodoModal = false)}
|
||||||
|
>
|
||||||
|
<div class="modal-card" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>🎯 New todo</h3>
|
||||||
|
<p class="modal-sub">Something you want to get done this week? Add it here.</p>
|
||||||
|
<input
|
||||||
|
class="modal-input"
|
||||||
|
type="text"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="e.g. Tidy my desk"
|
||||||
|
bind:value={newTodoName}
|
||||||
|
disabled={todoSaving}
|
||||||
|
autofocus
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && submitTodo()}
|
||||||
|
/>
|
||||||
|
{#if todoError}
|
||||||
|
<p class="modal-error">{todoError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-cancel"
|
||||||
|
onclick={() => (showTodoModal = false)}
|
||||||
|
disabled={todoSaving}>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="modal-btn modal-add"
|
||||||
|
onclick={submitTodo}
|
||||||
|
disabled={!newTodoName.trim() || todoSaving}
|
||||||
|
>{todoSaving ? 'Adding…' : 'Add todo'}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="wallet">
|
<div class="wallet">
|
||||||
<div class="wallet-top">
|
<div class="wallet-top">
|
||||||
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
<span class="wallet-lbl">{@html walletIcon} Wallet</span>
|
||||||
@@ -3001,6 +3066,93 @@
|
|||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.add-todo-cta {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
border: 1.5px dashed #c7d2fe;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #4338ca;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add-todo-cta:hover {
|
||||||
|
background: #e0e7ff;
|
||||||
|
border-color: #818cf8;
|
||||||
|
}
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.modal-card h3 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.modal-sub {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.modal-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1.5px solid #d1d5db;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.modal-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px #e0e7ff;
|
||||||
|
}
|
||||||
|
.modal-error {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.modal-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.modal-cancel {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.modal-add {
|
||||||
|
background: #4338ca;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.todo-main {
|
.todo-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit';
|
||||||
|
import { pbAdmin } from '$lib/server/pocketbase';
|
||||||
|
import { weekStart, addDaysStr, todayInTz, resolveTz } from '@shared/timezone';
|
||||||
|
|
||||||
|
// Kids can create todos for THEMSELVES only. Writes go through the superuser
|
||||||
|
// client because `assigned_chores.createRule` is parent-only — but every
|
||||||
|
// privileged field is forced server-side: memberId = session user, emoji type
|
||||||
|
// with zero value (so nobody can mint cash/points for themselves), weekly
|
||||||
|
// frequency, this-week start/completeBy (so it expires and purges naturally).
|
||||||
|
export async function POST(event: RequestEvent) {
|
||||||
|
const u = event.locals.user;
|
||||||
|
if (!u) throw error(401, 'Unauthorized');
|
||||||
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
|
||||||
|
if (!name) throw error(400, 'Give your todo a name');
|
||||||
|
|
||||||
|
let payday = 1;
|
||||||
|
let tz = 'UTC';
|
||||||
|
try {
|
||||||
|
const fam: any = await pbAdmin.getOne('fams', u.famId);
|
||||||
|
if (fam.payday !== undefined && fam.payday !== null) payday = Number(fam.payday);
|
||||||
|
tz = resolveTz(fam.timezone || 'auto');
|
||||||
|
} catch {
|
||||||
|
tz = resolveTz('auto');
|
||||||
|
}
|
||||||
|
const today = todayInTz(tz);
|
||||||
|
const completeBy = addDaysStr(weekStart(payday, tz), 6);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await pbAdmin.create('assigned_chores', {
|
||||||
|
famId: u.famId,
|
||||||
|
memberId: u.id,
|
||||||
|
frequency: 'weekly',
|
||||||
|
type: 'emoji',
|
||||||
|
value: 0,
|
||||||
|
customName: name,
|
||||||
|
isTodo: true,
|
||||||
|
emoji: '🎯',
|
||||||
|
startDate: today,
|
||||||
|
completeBy
|
||||||
|
});
|
||||||
|
return json({ record });
|
||||||
|
} catch (e) {
|
||||||
|
throw error(400, e instanceof Error ? e.message : 'Could not add todo');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user