make major bonus updates

This commit is contained in:
JCEEE
2026-07-31 16:13:05 +01:00
parent 8834281b4d
commit d720f4e863
12 changed files with 4174 additions and 2598 deletions
+126 -25
View File
@@ -9,15 +9,20 @@ function sessionHeaders(event: RequestEvent): Record<string, string> {
return {
'x-session-famid': s.famId,
'x-session-userid': s.userId,
'Content-Type': 'application/json',
'Content-Type': 'application/json'
};
}
async function request(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
async function request(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>
) {
const res = await fetch(`${HONO_URL}${path}`, {
method,
headers: headers || { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
body: body ? JSON.stringify(body) : undefined
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
@@ -29,14 +34,30 @@ export const hono = {
async list(event: RequestEvent, resource: string, famId: string) {
return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event));
},
async create(event: RequestEvent, resource: string, famId: string, data: Record<string, unknown>) {
async create(
event: RequestEvent,
resource: string,
famId: string,
data: Record<string, unknown>
) {
return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event));
},
async update(event: RequestEvent, resource: string, famId: string, id: string, data: Record<string, unknown>) {
async update(
event: RequestEvent,
resource: string,
famId: string,
id: string,
data: Record<string, unknown>
) {
return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event));
},
async remove(event: RequestEvent, resource: string, famId: string, id: string) {
return request('DELETE', `/api/admin/${famId}/${resource}/${id}`, undefined, sessionHeaders(event));
return request(
'DELETE',
`/api/admin/${famId}/${resource}/${id}`,
undefined,
sessionHeaders(event)
);
},
async renameFam(event: RequestEvent, famId: string, name: string) {
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
@@ -63,46 +84,126 @@ export const hono = {
return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event));
},
async claimReward(event: RequestEvent, famId: string, rewardId: string) {
return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event));
return request(
'POST',
`/api/admin/${famId}/rewards/${rewardId}/claim`,
undefined,
sessionHeaders(event)
);
},
async issueAllRewards(event: RequestEvent, famId: string, memberId: string, message?: string) {
return request('POST', `/api/admin/${famId}/rewards/issue-all`, { memberId, message }, sessionHeaders(event));
return request(
'POST',
`/api/admin/${famId}/rewards/issue-all`,
{ memberId, message },
sessionHeaders(event)
);
},
async bonusConfigs(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
},
async bonusConfigProgress(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/progress`, undefined, sessionHeaders(event));
return request(
'GET',
`/api/admin/${famId}/bonus-configs/progress`,
undefined,
sessionHeaders(event)
);
},
async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/evaluate`, { configId }, sessionHeaders(event));
return request(
'POST',
`/api/admin/${famId}/bonus-configs/evaluate`,
{ configId },
sessionHeaders(event)
);
},
async triggerBonusConfig(event: RequestEvent, famId: string, configId: string, memberId?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/trigger`, { memberId }, sessionHeaders(event));
async triggerBonusConfig(
event: RequestEvent,
famId: string,
configId: string,
memberId?: string
) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/trigger`,
{ memberId },
sessionHeaders(event)
);
},
async assignBonusConfig(event: RequestEvent, famId: string, configId: string, data: Record<string, unknown>) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/assign`, data, sessionHeaders(event));
async assignBonusConfig(
event: RequestEvent,
famId: string,
configId: string,
data: Record<string, unknown>
) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/assign`,
data,
sessionHeaders(event)
);
},
async completeBonusConfig(event: RequestEvent, famId: string, configId: string, phase?: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, sessionHeaders(event));
async completeBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/complete`,
undefined,
sessionHeaders(event)
);
},
async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) {
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/destroy`, undefined, sessionHeaders(event));
return request(
'POST',
`/api/admin/${famId}/bonus-configs/${configId}/destroy`,
undefined,
sessionHeaders(event)
);
},
async bonusConfigTallies(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/bonus-configs/tallies`, undefined, sessionHeaders(event));
return request(
'GET',
`/api/admin/${famId}/bonus-configs/tallies`,
undefined,
sessionHeaders(event)
);
},
async revokeCompletion(event: RequestEvent, famId: string, completionId: string) {
return request('POST', `/api/admin/${famId}/completions/${completionId}/revoke`, undefined, sessionHeaders(event));
return request(
'POST',
`/api/admin/${famId}/completions/${completionId}/revoke`,
undefined,
sessionHeaders(event)
);
},
async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) {
return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, sessionHeaders(event));
return request(
'POST',
`/api/admin/${famId}/send-message`,
{ memberId, message },
sessionHeaders(event)
);
},
async memberChores(event: RequestEvent, famId: string, memberId: string) {
return request('GET', `/api/admin/${famId}/members/${memberId}/chores`, undefined, sessionHeaders(event));
return request(
'GET',
`/api/admin/${famId}/members/${memberId}/chores`,
undefined,
sessionHeaders(event)
);
},
async updateMember(event: RequestEvent, famId: string, memberId: string, data: Record<string, unknown>) {
return request('PATCH', `/api/admin/${famId}/members/${memberId}`, data, sessionHeaders(event));
async updateMember(
event: RequestEvent,
famId: string,
memberId: string,
data: Record<string, unknown>
) {
return request(
'PATCH',
`/api/admin/${famId}/members/${memberId}`,
data,
sessionHeaders(event)
);
},
async getProfile(event: RequestEvent, famId: string) {
return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event));
@@ -115,6 +216,6 @@ export const hono = {
},
async request(event: RequestEvent, method: string, path: string, body?: unknown) {
return request(method, path, body, sessionHeaders(event));
},
},
}
}
};
+170 -90
View File
@@ -1,99 +1,145 @@
import { pb } from '$lib/pocketbase';
import type {
Member, ChoreTemplate, AssignedChore, Completion,
WeeklyHistory, Reward, BonusConfig, Fam, Season,
Member,
ChoreTemplate,
AssignedChore,
Completion,
WeeklyHistory,
Reward,
BonusConfig,
BonusTemplate,
Fam,
Season
} from '$lib/types';
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards' | 'seasons';
type CollectionName =
| 'members'
| 'chore_templates'
| 'assigned_chores'
| 'completions'
| 'bonus_configs'
| 'bonus_templates'
| 'rewards'
| 'seasons';
class FamStore {
fam = $state<Fam | null>(null)
members = $state<Member[]>([])
templates = $state<ChoreTemplate[]>([])
assigned = $state<AssignedChore[]>([])
completions = $state<Completion[]>([])
history = $state<WeeklyHistory[]>([])
rewards = $state<Reward[]>([])
bonusConfigs = $state<BonusConfig[]>([])
seasons = $state<Season[]>([])
initialized = $state(false)
famId = $state('')
fam = $state<Fam | null>(null);
members = $state<Member[]>([]);
templates = $state<ChoreTemplate[]>([]);
assigned = $state<AssignedChore[]>([]);
completions = $state<Completion[]>([]);
history = $state<WeeklyHistory[]>([]);
rewards = $state<Reward[]>([]);
bonusConfigs = $state<BonusConfig[]>([]);
bonusTemplates = $state<BonusTemplate[]>([]);
seasons = $state<Season[]>([]);
initialized = $state(false);
famId = $state('');
private unsubs: (() => void)[] = []
private destroyed = false
private unsubs: (() => void)[] = [];
private destroyed = false;
memberMap(): Map<string, Member> {
return new Map(this.members.map((m) => [m.id, m]))
return new Map(this.members.map((m) => [m.id, m]));
}
templateMap(): Map<string, ChoreTemplate> {
return new Map(this.templates.map((t) => [t.id, t]))
return new Map(this.templates.map((t) => [t.id, t]));
}
bonusConfigMap(): Map<string, BonusConfig> {
return new Map(this.bonusConfigs.map((b) => [b.id, b]))
return new Map(this.bonusConfigs.map((b) => [b.id, b]));
}
assignedForMember(memberId: string): AssignedChore[] {
return this.assigned.filter((a) => a.memberId === memberId)
return this.assigned.filter((a) => a.memberId === memberId);
}
completionsForDate(date: string): Completion[] {
return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date)
return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date);
}
isCompleted(assignedChoreId: string, date: string): boolean {
return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date)
return this.completions.some(
(c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date
);
}
private initPromise: Promise<void> | null = null
private initPromise: Promise<void> | null = null;
async init(famId: string) {
if (this.initialized && this.famId === famId) return
if (this.initialized && this.famId === famId) return;
// Wait for any in-flight init to finish first
if (this.initPromise) {
await this.initPromise
if (this.initialized && this.famId === famId) return
await this.initPromise;
if (this.initialized && this.famId === famId) return;
}
this.cleanup()
this.destroyed = false
this.famId = famId
this.cleanup();
this.destroyed = false;
this.famId = famId;
this.initPromise = (async () => {
try {
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes, seasonsRes] =
await Promise.all([
pb.collection('fams').getOne(famId) as Promise<Fam>,
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<ChoreTemplate[]>,
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<AssignedChore[]>,
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>,
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>,
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>,
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise<Season[]>,
])
this.fam = famRes
this.members = membersRes
this.templates = templatesRes
this.assigned = assignedRes
this.completions = completionsRes
this.bonusConfigs = bonusConfigsRes
this.rewards = rewardsRes
this.seasons = seasonsRes
this.initialized = true
const [
famRes,
membersRes,
templatesRes,
assignedRes,
completionsRes,
bonusConfigsRes,
bonusTemplatesRes,
rewardsRes,
seasonsRes
] = await Promise.all([
pb.collection('fams').getOne(famId) as Promise<Fam>,
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Member[]
>,
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
ChoreTemplate[]
>,
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<
AssignedChore[]
>,
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Completion[]
>,
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<
BonusConfig[]
>,
pb.collection('bonus_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<
BonusTemplate[]
>,
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Reward[]
>,
pb.collection('seasons').getFullList({ filter: `famId = '${famId}'` }) as Promise<
Season[]
>
]);
this.fam = famRes;
this.members = membersRes;
this.templates = templatesRes;
this.assigned = assignedRes;
this.completions = completionsRes;
this.bonusConfigs = bonusConfigsRes;
this.bonusTemplates = bonusTemplatesRes;
this.rewards = rewardsRes;
this.seasons = seasonsRes;
this.initialized = true;
} catch (e) {
console.error('FamStore.init failed:', e)
this.initPromise = null
throw e
console.error('FamStore.init failed:', e);
this.initPromise = null;
throw e;
}
await this.subscribe()
this.initPromise = null
})()
await this.subscribe();
this.initPromise = null;
})();
return this.initPromise!
return this.initPromise!;
}
private async subscribe() {
@@ -103,58 +149,92 @@ class FamStore {
{ collection: 'assigned_chores', filter: this.famId },
{ collection: 'completions', filter: this.famId },
{ collection: 'bonus_configs', filter: this.famId },
{ collection: 'bonus_templates', filter: this.famId },
{ collection: 'rewards', filter: this.famId },
{ collection: 'seasons', filter: this.famId },
]
{ collection: 'seasons', filter: this.famId }
];
const promises = subs.map(({ collection, filter }) => {
const filterStr = filter ? `famId = '${filter}'` : ''
return pb.collection(collection).subscribe('*', (data: any) => {
if (this.destroyed) return
this.handleRealtime(collection, data.action, data.record)
}, { filter: filterStr || undefined }).then((unsub) => {
if (this.destroyed) { unsub(); return }
this.unsubs.push(unsub)
}).catch((err: Error) => {
console.error(`[famStore] subscribe failed for ${collection}:`, err)
})
})
const filterStr = filter ? `famId = '${filter}'` : '';
return pb
.collection(collection)
.subscribe(
'*',
(data: any) => {
if (this.destroyed) return;
this.handleRealtime(collection, data.action, data.record);
},
{ filter: filterStr || undefined }
)
.then((unsub) => {
if (this.destroyed) {
unsub();
return;
}
this.unsubs.push(unsub);
})
.catch((err: Error) => {
console.error(`[famStore] subscribe failed for ${collection}:`, err);
});
});
await Promise.allSettled(promises)
await Promise.allSettled(promises);
}
// Called from form action callbacks for instant UI feedback,
// and from PB subscribe SSE for multi-user realtime.
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
const apply = <T extends { id: string }>(list: T[]): T[] => {
if (action === 'create') return [record, ...list]
if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
if (action === 'delete') return list.filter((x) => x.id !== record.id)
return list
}
if (action === 'create') return [record, ...list];
if (action === 'update')
return list.map((x) => (x.id === record.id ? { ...x, ...record } : x));
if (action === 'delete') return list.filter((x) => x.id !== record.id);
return list;
};
switch (collection) {
case 'members': this.members = apply(this.members); break
case 'chore_templates': this.templates = apply(this.templates); break
case 'assigned_chores': this.assigned = apply(this.assigned); break
case 'completions': this.completions = apply(this.completions); break
case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break
case 'rewards': this.rewards = apply(this.rewards); break
case 'seasons': this.seasons = apply(this.seasons); break
case 'members':
this.members = apply(this.members);
break;
case 'chore_templates':
this.templates = apply(this.templates);
break;
case 'assigned_chores':
this.assigned = apply(this.assigned);
break;
case 'completions':
this.completions = apply(this.completions);
break;
case 'bonus_configs':
this.bonusConfigs = apply(this.bonusConfigs);
break;
case 'bonus_templates':
this.bonusTemplates = apply(this.bonusTemplates);
break;
case 'rewards':
this.rewards = apply(this.rewards);
break;
case 'seasons':
this.seasons = apply(this.seasons);
break;
}
}
private handleRealtime(collection: string, action: string, record: any) {
this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete')
this.applyRecord(
collection as CollectionName,
record,
action as 'create' | 'update' | 'delete'
);
}
cleanup() {
// TODO - we need to properly unsubscribe from pocketbase
this.destroyed = true
for (const unsub of this.unsubs) unsub()
this.unsubs = []
this.initialized = false
cleanup() {
// TODO - we need to properly unsubscribe from pocketbase
this.destroyed = true;
for (const unsub of this.unsubs) unsub();
this.unsubs = [];
this.initialized = false;
}
}
export const famStore = new FamStore()
export const famStore = new FamStore();
+132 -117
View File
@@ -1,156 +1,171 @@
export type Frequency = 'daily' | 'weekly'
export type RewardType = 'points' | 'money'
export type BonusTarget = 'individual' | 'competitive' | 'collaborative'
export type BonusType = 'threshold' | 'count' | 'manual'
export type BonusOccurrence = 'recurring' | 'once'
export type BonusRewardType = 'points' | 'cash' | 'prize'
export type BonusPeriod = 'weekly' | 'monthly'
export type BonusStatus = 'active' | 'archived'
export type BonusState = 'pending' | 'unclaimed' | 'claimed'
export type Frequency = 'daily' | 'weekly';
export type RewardType = 'points' | 'money';
export type BonusTarget = 'individual' | 'competitive' | 'collaborative';
export type BonusType = 'threshold' | 'count' | 'manual';
export type BonusOccurrence = 'recurring' | 'once';
export type BonusRewardType = 'points' | 'cash' | 'prize';
export type BonusPeriod = 'weekly' | 'monthly' | 'daily';
export type BonusStatus = 'active' | 'completed' | 'disabled';
export type BonusState = 'pending' | 'unclaimed' | 'claimed';
export interface BonusTemplate {
id: string;
famId: string;
name: string;
description?: string;
target: BonusTarget;
type: BonusType;
occurrence: BonusOccurrence;
rewardType: BonusRewardType;
rewardValue: string;
criteriaValue?: number;
period?: BonusPeriod;
created: string;
updated: string;
}
export interface Season {
id: string
famId: string
name: string
color: string
active: boolean
autoDisable?: string
autoStart?: string
created: string
updated: string
id: string;
famId: string;
name: string;
color: string;
active: boolean;
autoDisable?: string;
autoStart?: string;
created: string;
updated: string;
}
export interface Fam {
id: string
name: string
slug: string
inviteCode: string
stripeCustomerId?: string
featureFlags: Record<string, boolean>
created: string
updated: string
id: string;
name: string;
slug: string;
inviteCode: string;
stripeCustomerId?: string;
featureFlags: Record<string, boolean>;
created: string;
updated: string;
}
export interface Member {
id: string
famId: string
name: string
color: string
deviceToken: string
deviceTokenHint: string
created: string
updated: string
id: string;
famId: string;
name: string;
color: string;
deviceToken: string;
deviceTokenHint: string;
created: string;
updated: string;
}
export interface ChoreTemplate {
id: string
famId: string
name: string
description?: string
defaultFrequency: Frequency
defaultType: RewardType
defaultValue: number
created: string
updated: string
id: string;
famId: string;
name: string;
description?: string;
defaultFrequency: Frequency;
defaultType: RewardType;
defaultValue: number;
created: string;
updated: string;
}
export interface AssignedChore {
id: string
famId: string
memberId: string
templateId: string
frequency: Frequency
type: RewardType
value: number
customName?: string
seasonIds?: string[]
created: string
updated: string
id: string;
famId: string;
memberId: string;
templateId: string;
frequency: Frequency;
type: RewardType;
value: number;
customName?: string;
seasonIds?: string[];
created: string;
updated: string;
}
export interface Completion {
id: string
famId: string
memberId: string
assignedChoreId: string
date: string
completedAt: string
id: string;
famId: string;
memberId: string;
assignedChoreId: string;
date: string;
completedAt: string;
}
export interface WeeklyHistory {
id: string
famId: string
memberId: string
weekStart: string
pointsEarned: number
moneyEarned: number
choresCompleted: number
bonusEarned: number
id: string;
famId: string;
memberId: string;
weekStart: string;
pointsEarned: number;
moneyEarned: number;
choresCompleted: number;
bonusEarned: number;
}
export interface BonusConfig {
id: string
famId: string
name: string
description?: string
target: BonusTarget
memberId?: string
type: BonusType
occurrence: BonusOccurrence
rewardType: BonusRewardType
rewardValue: string
criteriaValue?: number
period?: BonusPeriod
status: BonusStatus
phase?: 'template' | 'ready' | 'active' | 'completed'
created: string
updated: string
id: string;
famId: string;
name: string;
description?: string;
target: BonusTarget;
memberId?: string;
type: BonusType;
occurrence: BonusOccurrence;
rewardType: BonusRewardType;
rewardValue: string;
criteriaValue?: number;
period?: BonusPeriod;
status: BonusStatus;
created: string;
updated: string;
}
export interface Reward {
id: string
famId: string
memberId: string
bonusConfigId?: string
label: string
value: number
rewardType: BonusRewardType
status: 'unclaimed' | 'requested' | 'claimed'
claimedAt?: string
requestedAt?: string
date: string
created: string
updated: string
id: string;
famId: string;
memberId: string;
bonusConfigId?: string;
label: string;
value: number;
rewardType: BonusRewardType;
status: 'unclaimed' | 'requested' | 'claimed';
claimedAt?: string;
requestedAt?: string;
date: string;
created: string;
updated: string;
}
export interface BonusProgress {
memberId: string
memberName: string
memberColor: string
current: number
criteriaValue: number
reward: { id: string; status: string } | null
state: BonusState
achieved: boolean
memberId: string;
memberName: string;
memberColor: string;
current: number;
criteriaValue: number;
reward: { id: string; status: string } | null;
state: BonusState;
achieved: boolean;
}
export interface BonusConfigWithProgress {
config: BonusConfig
progress: BonusProgress[]
periodStart: string
periodEnd: string
config: BonusConfig;
progress: BonusProgress[];
periodStart: string;
periodEnd: string;
}
export interface Settings {
id: string
famId: string
webhookUrl?: string
id: string;
famId: string;
webhookUrl?: string;
}
export interface Session {
famId: string
userId: string
famSlug: string
memberName?: string
role?: string
famId: string;
userId: string;
famSlug: string;
memberName?: string;
role?: string;
}