feat: 优化页面 UI 以及支持个人中心 练习足迹

This commit is contained in:
richarjiang
2026-09-08 11:26:46 +08:00
parent 87d946adb5
commit 301f9ae385
9 changed files with 347 additions and 411 deletions

View File

@@ -1542,4 +1542,44 @@ describe('BookingService', () => {
expect(tx.booking.create).not.toHaveBeenCalled()
})
})
describe('getPracticeActivity', () => {
afterEach(() => jest.restoreAllMocks())
it('uses China today across UTC midnight and counts scheduled dates without pagination', async () => {
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-07T16:01:00Z'))
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([
{ timeSlot: { date: new Date('2026-08-10T00:00:00Z') } },
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
])
const result = await service.getPracticeActivity(MOCK_USER_ID)
expect(result.days).toHaveLength(30)
expect(result.days[0]).toEqual({ date: '2026-08-10', count: 1 })
expect(result.days[29]).toEqual({ date: '2026-09-08', count: 2 })
expect(result.days[1]).toEqual({ date: '2026-08-11', count: 0 })
expect(prisma.booking.findMany).toHaveBeenCalledWith({
where: {
userId: MOCK_USER_ID,
status: BookingStatus.COMPLETED,
timeSlot: { date: {
gte: new Date('2026-08-10T00:00:00Z'),
lt: new Date('2026-09-09T00:00:00Z'),
} },
},
select: { timeSlot: { select: { date: true } } },
})
})
it('returns every day with zero counts across a leap-year boundary', async () => {
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2024-03-01T01:00:00Z'))
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
const result = await service.getPracticeActivity(MOCK_USER_ID)
expect(result.days).toHaveLength(30)
expect(result.days[0].date).toBe('2024-02-01')
expect(result.days[28].date).toBe('2024-02-29')
expect(result.days[29].date).toBe('2024-03-01')
expect(result.days.every(day => day.count === 0)).toBe(true)
})
})
})

View File

@@ -42,6 +42,12 @@ export class BookingController {
return this.bookingService.cancelBooking(userId, id)
}
@Get('booking/my/activity')
@UseGuards(JwtAuthGuard)
async getPracticeActivity(@CurrentUser('sub') userId: string) {
return this.bookingService.getPracticeActivity(userId)
}
@Get('booking/my/upcoming')
@UseGuards(JwtAuthGuard)
async getUpcomingBookings(@CurrentUser('sub') userId: string) {

View File

@@ -12,6 +12,7 @@ import {
MembershipStatus,
TimeSlotStatus,
type TeachingScheduleSlot,
type PracticeActivity,
} from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { MembershipService } from '../membership/membership.service'
@@ -670,6 +671,34 @@ export class BookingService {
// ─── Get Upcoming Bookings ────────────────────────────────────────────────
async getPracticeActivity(userId: string): Promise<PracticeActivity> {
// Slot dates are stored as UTC midnight date-only values. Determine today's
// calendar date in China independently of the server's timezone.
const dayMs = 86_400_000
const today = new Date(Date.now() + 8 * 3_600_000).toISOString().slice(0, 10)
const end = new Date(today + 'T00:00:00Z').getTime()
const start = end - 29 * dayMs
const bookings = await this.prisma.booking.findMany({
where: {
userId,
status: BookingStatus.COMPLETED,
timeSlot: { date: { gte: new Date(start), lt: new Date(end + dayMs) } },
},
select: { timeSlot: { select: { date: true } } },
})
const counts = new Map<string, number>()
for (const booking of bookings) {
const date = booking.timeSlot.date.toISOString().slice(0, 10)
counts.set(date, (counts.get(date) ?? 0) + 1)
}
return {
days: Array.from({ length: 30 }, (_, index) => {
const date = new Date(start + index * dayMs).toISOString().slice(0, 10)
return { date, count: counts.get(date) ?? 0 }
}),
}
}
async getUpcomingBookings(userId: string): Promise<BookingWithRelations[]> {
const today = new Date()
today.setUTCHours(0, 0, 0, 0)