feat: add monthly card periods and apparatus lesson feedback

This commit is contained in:
richarjiang
2026-10-18 15:47:29 +08:00
parent b452de379d
commit a3d58fa8f2
40 changed files with 968 additions and 170 deletions

View File

@@ -13,6 +13,7 @@ export enum CardTypeCategory {
// ===== Membership =====
export enum MembershipStatus {
SCHEDULED = 'SCHEDULED',
ACTIVE = 'ACTIVE',
EXPIRED = 'EXPIRED',
USED_UP = 'USED_UP',

View File

@@ -55,6 +55,10 @@ export {
MEMBERSHIP_DAY_MS,
addDurationDays,
getMembershipDaysLeft,
isMembershipExpired,
getMembershipEffectiveState,
getStudioSlotRange,
canUseMembershipForSlot,
canRenewCardType,
computeMembershipGrant,
pickRenewalTarget,
@@ -137,6 +141,7 @@ export type {
} from './types/index'
export * from './types/member-care'
export * from './lesson-feedback'
export * from './posture-assessment'
export * from './types/body-portrait'

View File

@@ -0,0 +1,20 @@
export const LESSON_APPARATUS = ['M', 'R', 'TT', 'Chair', 'ARC', 'SC', 'LB'] as const
export type LessonApparatus = typeof LESSON_APPARATUS[number]
export type ApparatusNotes = Partial<Record<LessonApparatus, string>>
export const LESSON_NOTE_MAX_LENGTH = 1000
export function isApparatusNotes(value: unknown): value is ApparatusNotes {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
return Object.entries(value).every(([key, content]) =>
LESSON_APPARATUS.includes(key as LessonApparatus) &&
typeof content === 'string' && content.length <= LESSON_NOTE_MAX_LENGTH,
)
}
// Keep a stable apparatus order and omit fields the coach left blank.
export function filledApparatusNotes(notes?: ApparatusNotes | null) {
return LESSON_APPARATUS.flatMap(apparatus => {
const content = notes?.[apparatus]?.trim()
return content ? [{ apparatus, content }] : []
})
}

View File

@@ -18,6 +18,7 @@ export interface MembershipGrantCardType {
}
export interface MembershipGrantResult {
readonly startDate: Date
readonly remainingTimes: number | null
readonly expireDate: Date
readonly totalTimes: number | null
@@ -34,6 +35,7 @@ export interface MembershipRenewalHint {
}
export interface RenewalHintMembership {
readonly startDate?: Date | string
readonly cardTypeId: string
readonly remainingTimes: number | null
readonly expireDate: Date | string
@@ -55,6 +57,30 @@ export function canRenewCardType(type: CardTypeCategory): boolean {
return type !== CardTypeCategory.TRIAL
}
export function isMembershipExpired(membership: { readonly status: string; readonly expireDate: Date | string }, now: Date = new Date()): boolean {
return membership.status === MembershipStatus.EXPIRED || new Date(membership.expireDate).getTime() <= now.getTime()
}
/** Effective benefits at read time; retain the stored balance for historical auditing. */
export function getMembershipEffectiveState(membership: {
readonly startDate?: Date | string
readonly status: string
readonly expireDate: Date | string
readonly remainingTimes: number | null
readonly cardType: { readonly type: string }
}, now: Date = new Date()): { status: MembershipStatus; remainingTimes: number | null } {
const expired = isMembershipExpired(membership, now)
const future = membership.startDate != null && new Date(membership.startDate).getTime() > now.getTime()
return {
status: expired ? MembershipStatus.EXPIRED : future ? MembershipStatus.SCHEDULED
: membership.status === MembershipStatus.SCHEDULED
? membership.remainingTimes === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
: membership.status as MembershipStatus,
remainingTimes: expired && membership.cardType.type === CardTypeCategory.DURATION && membership.remainingTimes !== null
? 0 : membership.remainingTimes,
}
}
export function computeMembershipGrant(params: {
readonly existing: MembershipGrantExisting | null
readonly cardType: MembershipGrantCardType
@@ -64,6 +90,7 @@ export function computeMembershipGrant(params: {
if (!existing || cardType.type === CardTypeCategory.TRIAL) {
return {
startDate: now,
remainingTimes: cardType.totalTimes ?? null,
expireDate: addDurationDays(now, cardType.durationDays),
totalTimes: cardType.totalTimes ?? null,
@@ -72,13 +99,17 @@ export function computeMembershipGrant(params: {
}
const existingExpire = new Date(existing.expireDate)
const isExpired =
existing.status === MembershipStatus.EXPIRED || existingExpire.getTime() <= now.getTime()
const baseDate = existingExpire.getTime() > now.getTime() ? existingExpire : now
const isExpired = isMembershipExpired(existing, now)
const baseDate = isExpired ? now : existingExpire
const expireDate = addDurationDays(baseDate, cardType.durationDays)
if (cardType.type === CardTypeCategory.DURATION) {
return { startDate: baseDate, remainingTimes: cardType.totalTimes, totalTimes: cardType.totalTimes, expireDate, isRenewal: true }
}
if (cardType.totalTimes == null) {
return {
startDate: now,
remainingTimes: null,
expireDate,
totalTimes: null,
@@ -88,6 +119,7 @@ export function computeMembershipGrant(params: {
if (isExpired) {
return {
startDate: now,
remainingTimes: cardType.totalTimes,
expireDate: addDurationDays(now, cardType.durationDays),
totalTimes: cardType.totalTimes,
@@ -96,6 +128,7 @@ export function computeMembershipGrant(params: {
}
return {
startDate: now,
remainingTimes: (existing.remainingTimes ?? 0) + cardType.totalTimes,
expireDate,
totalTimes: (existing.totalTimes ?? existing.remainingTimes ?? 0) + cardType.totalTimes,
@@ -112,12 +145,14 @@ export function pickRenewalTarget<T extends RenewalHintMembership>(
.slice()
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
if (matches[0]?.cardType.type === CardTypeCategory.DURATION) {
return matches.find(m => m.status !== MembershipStatus.EXPIRED) ?? matches[0]
}
return matches[0] ?? null
}
function isUsableMembership(membership: RenewalHintMembership, now: Date): boolean {
if (membership.status !== MembershipStatus.ACTIVE) return false
if (new Date(membership.expireDate).getTime() <= now.getTime()) return false
if (getMembershipEffectiveState(membership, now).status !== MembershipStatus.ACTIVE) return false
return membership.remainingTimes === null || membership.remainingTimes > 0
}
@@ -187,6 +222,7 @@ export function getMembershipRenewalHint(
}
if (memberships.length === 0) return null
if (memberships.some(m => getMembershipEffectiveState(m, now).status === MembershipStatus.SCHEDULED)) return null
const renewable = memberships
.filter((m) => canRenewCardType(m.cardType.type))
@@ -197,7 +233,26 @@ export function getMembershipRenewalHint(
return {
kind: 'expired',
cardTypeId: recommended?.cardTypeId ?? null,
remainingTimes: recommended?.remainingTimes ?? null,
remainingTimes: recommended ? getMembershipEffectiveState(recommended, now).remainingTimes : null,
daysLeft: recommended ? getMembershipDaysLeft(recommended.expireDate, now) : null,
}
}
/** Studio dates are stored as UTC date-only values; clock times are China Standard Time. */
export function getStudioSlotRange(date: Date | string, startTime: string, endTime: string): { start: Date; end: Date } {
const day = typeof date === 'string' ? date.slice(0, 10) : date.toISOString().slice(0, 10)
return { start: new Date(`${day}T${startTime.slice(0, 5)}:00+08:00`), end: new Date(`${day}T${endTime.slice(0, 5)}:00+08:00`) }
}
export function canUseMembershipForSlot(membership: {
readonly startDate?: Date | string; readonly expireDate: Date | string; readonly status: string
readonly remainingTimes: number | null; readonly cardType: { readonly type: string }
}, slot: { readonly date: Date | string; readonly startTime: string; readonly endTime: string }, now: Date = new Date()): boolean {
const state = getMembershipEffectiveState(membership, now)
if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) return false
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
if (membership.cardType.type !== CardTypeCategory.DURATION) return state.status === MembershipStatus.ACTIVE
const { start, end } = getStudioSlotRange(slot.date, slot.startTime, slot.endTime)
return Number.isFinite(start.getTime()) && end > start
&& (membership.startDate == null || start >= new Date(membership.startDate)) && end <= new Date(membership.expireDate)
}

View File

@@ -14,6 +14,7 @@ export interface BodyMetricRecord {
}
export interface MemberNoteRecord {
id: string; content: string; shared: boolean; createdAt: string; bookingId: string | null
apparatusNotes?: import('../lesson-feedback').ApparatusNotes | null
booking: { timeSlot: { date: string; startTime: string } } | null
}
export interface ProgressPhotoRecord {