feat: routines module; super admin subscription badge, 7-day trial and lifetime plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
RoutineCalendarDayDto,
|
||||
RoutineCategoryStatDto,
|
||||
RoutineDayState,
|
||||
RoutineExecutionRowDto,
|
||||
RoutineHistoryDayDto,
|
||||
RoutineMemberStatDto,
|
||||
RoutineMonthlyReportDto,
|
||||
RoutinePerformanceDto,
|
||||
RoutinePeriodTotalsDto,
|
||||
RoutineScoreDto,
|
||||
RoutineStatusBreakdownDto,
|
||||
RoutineTaskStatus,
|
||||
RoutineTeamPerformanceDto,
|
||||
RoutineTrendPointDto,
|
||||
RoutineWeeklyReportDto,
|
||||
} from '@mento/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { RoutineRangeQueryDto } from './dto';
|
||||
import { describeRecurrence } from './recurrence';
|
||||
import { routineRule } from './routine-mapper';
|
||||
import { type RoutineScope, assertManager, canView } from './routine-scope';
|
||||
import {
|
||||
addDays,
|
||||
dateToDayKey,
|
||||
dayDiff,
|
||||
dayKeyToDate,
|
||||
eachDay,
|
||||
jalaliDayOfMonth,
|
||||
tehranDayKey,
|
||||
weekStartKey,
|
||||
} from './routine-dates';
|
||||
|
||||
type StatTask = {
|
||||
assigneeId: string;
|
||||
scheduledFor: Date;
|
||||
status: RoutineTaskStatus;
|
||||
isMandatory: boolean;
|
||||
categoryId: string | null;
|
||||
routineId: string | null;
|
||||
source: string;
|
||||
dueAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type Counts = {
|
||||
total: number;
|
||||
completed: number;
|
||||
overdue: number;
|
||||
skipped: number;
|
||||
cancelled: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
const statSelect = {
|
||||
assigneeId: true,
|
||||
scheduledFor: true,
|
||||
status: true,
|
||||
isMandatory: true,
|
||||
categoryId: true,
|
||||
routineId: true,
|
||||
source: true,
|
||||
dueAt: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
} satisfies Prisma.RoutineTaskSelect;
|
||||
|
||||
function emptyCounts(): Counts {
|
||||
return { total: 0, completed: 0, overdue: 0, skipped: 0, cancelled: 0, pending: 0 };
|
||||
}
|
||||
|
||||
function addTo(c: Counts, status: RoutineTaskStatus, n = 1) {
|
||||
c.total += n;
|
||||
if (status === 'completed') c.completed += n;
|
||||
else if (status === 'overdue') c.overdue += n;
|
||||
else if (status === 'skipped') c.skipped += n;
|
||||
else if (status === 'cancelled') c.cancelled += n;
|
||||
else c.pending += n;
|
||||
}
|
||||
|
||||
/** Taskهای لغو و رد شده در درصد حساب نمیشوند */
|
||||
export function effectiveTotal(c: Counts): number {
|
||||
return c.total - c.cancelled - c.skipped;
|
||||
}
|
||||
|
||||
export function percentOf(c: Counts): number | null {
|
||||
const eff = effectiveTotal(c);
|
||||
return eff > 0 ? Math.round((c.completed / eff) * 100) : null;
|
||||
}
|
||||
|
||||
export function dayState(c: Counts, day: string, today: string): RoutineDayState {
|
||||
const eff = effectiveTotal(c);
|
||||
if (eff <= 0 || day > today) return 'empty';
|
||||
if (c.completed >= eff) return 'complete';
|
||||
if (c.completed === 0 && day < today) return 'missed';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
export function jalaliMonthStart(day: string): string {
|
||||
return addDays(day, -(jalaliDayOfMonth(day) - 1));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RoutineStatsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/* ───────── امتیاز روزانه و Streak ───────── */
|
||||
|
||||
async score(organizationId: string, userId: string, day = tehranDayKey()): Promise<RoutineScoreDto> {
|
||||
const monthStart = jalaliMonthStart(day);
|
||||
const weekStart = weekStartKey(day);
|
||||
const from = [monthStart, weekStart, addDays(day, -1)].sort()[0]!;
|
||||
const tasks = await this.prisma.routineTask.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
assigneeId: userId,
|
||||
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(day) },
|
||||
},
|
||||
select: { scheduledFor: true, status: true },
|
||||
});
|
||||
const range = (a: string, b: string) => {
|
||||
const c = emptyCounts();
|
||||
for (const t of tasks) {
|
||||
const k = dateToDayKey(t.scheduledFor);
|
||||
if (k >= a && k <= b) addTo(c, t.status);
|
||||
}
|
||||
return percentOf(c);
|
||||
};
|
||||
const yesterday = addDays(day, -1);
|
||||
return {
|
||||
today: range(day, day),
|
||||
yesterday: range(yesterday, yesterday),
|
||||
week: range(weekStart, day),
|
||||
month: range(monthStart, day),
|
||||
streak: (await this.streaks(organizationId, [userId], day)).get(userId) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** روزهای متوالی که همه Taskهای اجباری انجام (یا مجاز رد) شدهاند؛ روز بدون Task اجباری خنثی است */
|
||||
async streaks(
|
||||
organizationId: string,
|
||||
userIds: string[],
|
||||
today = tehranDayKey(),
|
||||
): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
if (!userIds.length) return out;
|
||||
const groups = await this.prisma.routineTask.groupBy({
|
||||
by: ['assigneeId', 'scheduledFor', 'status'],
|
||||
where: {
|
||||
organizationId,
|
||||
assigneeId: { in: userIds },
|
||||
isMandatory: true,
|
||||
status: { not: 'cancelled' },
|
||||
scheduledFor: { gte: dayKeyToDate(addDays(today, -180)), lte: dayKeyToDate(today) },
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
const byUser = new Map<string, Map<string, { total: number; ok: number }>>();
|
||||
for (const g of groups) {
|
||||
const days = byUser.get(g.assigneeId) ?? new Map();
|
||||
const key = dateToDayKey(g.scheduledFor);
|
||||
const cur = days.get(key) ?? { total: 0, ok: 0 };
|
||||
cur.total += g._count._all;
|
||||
if (g.status === 'completed' || g.status === 'skipped') cur.ok += g._count._all;
|
||||
days.set(key, cur);
|
||||
byUser.set(g.assigneeId, days);
|
||||
}
|
||||
for (const userId of userIds) {
|
||||
const days = byUser.get(userId);
|
||||
let streak = 0;
|
||||
if (days) {
|
||||
const keys = [...days.keys()].sort().reverse();
|
||||
for (const key of keys) {
|
||||
const d = days.get(key)!;
|
||||
const complete = d.ok >= d.total;
|
||||
if (key === today) {
|
||||
if (complete) streak += 1;
|
||||
continue;
|
||||
}
|
||||
if (!complete) break;
|
||||
streak += 1;
|
||||
}
|
||||
}
|
||||
out.set(userId, streak);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ───────── خلاصه روزها (تقویم/تاریخچه) ───────── */
|
||||
|
||||
summarizeDays(tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>, from: string, to: string) {
|
||||
const today = tehranDayKey();
|
||||
const map = new Map<string, Counts>();
|
||||
for (const t of tasks) {
|
||||
const k = dateToDayKey(t.scheduledFor);
|
||||
const c = map.get(k) ?? emptyCounts();
|
||||
addTo(c, t.status);
|
||||
map.set(k, c);
|
||||
}
|
||||
return eachDay(from, to).map((date) => {
|
||||
const c = map.get(date) ?? emptyCounts();
|
||||
return { date, counts: c, state: dayState(c, date, today), percent: percentOf(c) };
|
||||
});
|
||||
}
|
||||
|
||||
toCalendarDays(
|
||||
tasks: Array<{ scheduledFor: Date; status: RoutineTaskStatus }>,
|
||||
from: string,
|
||||
to: string,
|
||||
): RoutineCalendarDayDto[] {
|
||||
return this.summarizeDays(tasks, from, to).map(({ date, counts, state, percent }) => ({
|
||||
date,
|
||||
total: effectiveTotal(counts),
|
||||
completed: counts.completed,
|
||||
overdue: counts.overdue,
|
||||
percent,
|
||||
state,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ───────── گزارشها ───────── */
|
||||
|
||||
async performance(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutinePerformanceDto> {
|
||||
const userId = q.userId || scope.userId;
|
||||
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
|
||||
const { from, to } = this.range(q, 29);
|
||||
const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]);
|
||||
const agg = await this.aggregate(scope.organizationId, tasks, from, to);
|
||||
const streak = (await this.streaks(scope.organizationId, [userId])).get(userId) ?? 0;
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
total: agg.effTotal,
|
||||
completed: agg.counts.completed,
|
||||
notDone: agg.status.notDone,
|
||||
overdue: agg.counts.overdue,
|
||||
percent: percentOf(agg.counts),
|
||||
avgCompletionMinutes: this.avgCompletion(tasks),
|
||||
completeDays: agg.days.complete,
|
||||
partialDays: agg.days.partial,
|
||||
missedDays: agg.days.missed,
|
||||
streak,
|
||||
status: agg.status,
|
||||
trend: agg.trend,
|
||||
byCategory: agg.byCategory,
|
||||
};
|
||||
}
|
||||
|
||||
async team(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineTeamPerformanceDto> {
|
||||
assertManager(scope);
|
||||
const { from, to } = this.range(q, 29);
|
||||
const users = await this.teamUsers(scope, q);
|
||||
const tasks = await this.fetch(scope.organizationId, from, to, q, users.map((u) => u.id));
|
||||
const agg = await this.aggregate(scope.organizationId, tasks, from, to);
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
total: agg.effTotal,
|
||||
completed: agg.counts.completed,
|
||||
overdue: agg.counts.overdue,
|
||||
percent: percentOf(agg.counts),
|
||||
status: agg.status,
|
||||
trend: agg.trend,
|
||||
byCategory: agg.byCategory,
|
||||
members: await this.memberStats(scope.organizationId, users, tasks),
|
||||
};
|
||||
}
|
||||
|
||||
async weekly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineWeeklyReportDto> {
|
||||
const from = weekStartKey(q.from || tehranDayKey());
|
||||
const to = addDays(from, 6);
|
||||
const prevFrom = addDays(from, -7);
|
||||
const userIds = await this.viewUsers(scope, q);
|
||||
const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds);
|
||||
const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from);
|
||||
const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from);
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
days: this.toCalendarDays(current, from, to),
|
||||
totals: this.periodTotals(current, from, to),
|
||||
previous: this.periodTotals(previous, prevFrom, addDays(from, -1)),
|
||||
};
|
||||
}
|
||||
|
||||
async monthly(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineMonthlyReportDto> {
|
||||
const from = q.from || jalaliMonthStart(tehranDayKey());
|
||||
const to = q.to || addDays(jalaliMonthStart(addDays(from, 32)), -1);
|
||||
const length = dayDiff(from, to) + 1;
|
||||
const prevTo = addDays(from, -1);
|
||||
const prevFrom = addDays(from, -length);
|
||||
const userIds = await this.viewUsers(scope, q);
|
||||
const tasks = await this.fetch(scope.organizationId, prevFrom, to, q, userIds);
|
||||
const current = tasks.filter((t) => dateToDayKey(t.scheduledFor) >= from);
|
||||
const previous = tasks.filter((t) => dateToDayKey(t.scheduledFor) < from);
|
||||
const status = this.statusBreakdown(current);
|
||||
let byUser: RoutineMemberStatDto[] = [];
|
||||
if (q.view === 'team') {
|
||||
const users = await this.teamUsers(scope, q);
|
||||
byUser = await this.memberStats(scope.organizationId, users, current);
|
||||
}
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
totals: this.periodTotals(current, from, to),
|
||||
previous: this.periodTotals(previous, prevFrom, prevTo),
|
||||
status,
|
||||
days: this.toCalendarDays(current, from, to),
|
||||
byUser,
|
||||
};
|
||||
}
|
||||
|
||||
async routinesExecution(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineExecutionRowDto[]> {
|
||||
assertManager(scope);
|
||||
const { from, to } = this.range(q, 29);
|
||||
const users = await this.teamUsers(scope, q);
|
||||
const where: Prisma.RoutineTaskWhereInput = {
|
||||
organizationId: scope.organizationId,
|
||||
routineId: { not: null },
|
||||
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
|
||||
assigneeId: { in: users.map((u) => u.id) },
|
||||
status: { notIn: ['cancelled'] },
|
||||
...(q.categoryId ? { categoryId: q.categoryId } : {}),
|
||||
};
|
||||
const groups = await this.prisma.routineTask.groupBy({
|
||||
by: ['routineId', 'status'],
|
||||
where,
|
||||
_count: { _all: true },
|
||||
});
|
||||
const map = new Map<string, Counts>();
|
||||
for (const g of groups) {
|
||||
if (!g.routineId) continue;
|
||||
const c = map.get(g.routineId) ?? emptyCounts();
|
||||
addTo(c, g.status, g._count._all);
|
||||
map.set(g.routineId, c);
|
||||
}
|
||||
if (!map.size) return [];
|
||||
const routines = await this.prisma.routine.findMany({
|
||||
where: { id: { in: [...map.keys()] } },
|
||||
include: { assignee: { select: { fullName: true } }, team: { select: { name: true } } },
|
||||
});
|
||||
return routines
|
||||
.map((r) => {
|
||||
const c = map.get(r.id)!;
|
||||
const resolved = c.completed + c.overdue + c.skipped;
|
||||
return {
|
||||
routineId: r.id,
|
||||
title: r.deletedAt ? `${r.title} (حذفشده)` : r.title,
|
||||
assigneeLabel: r.assignee?.fullName ?? r.team?.name ?? '—',
|
||||
recurrenceLabel: describeRecurrence(routineRule(r)),
|
||||
occurrences: c.total,
|
||||
completed: c.completed,
|
||||
notDone: c.overdue + c.pending,
|
||||
skipped: c.skipped,
|
||||
percent: resolved ? Math.round((c.completed / resolved) * 100) : null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.percent ?? 101) - (b.percent ?? 101));
|
||||
}
|
||||
|
||||
async history(scope: RoutineScope, q: RoutineRangeQueryDto): Promise<RoutineHistoryDayDto[]> {
|
||||
const userId = q.userId || scope.userId;
|
||||
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
|
||||
const { from, to } = this.range(q, 59);
|
||||
const tasks = await this.fetch(scope.organizationId, from, to, { ...q, userId }, [userId]);
|
||||
return this.summarizeDays(tasks, from, to)
|
||||
.filter((d) => d.counts.total > 0)
|
||||
.reverse()
|
||||
.map(({ date, counts, state, percent }) => ({
|
||||
date,
|
||||
total: counts.total,
|
||||
completed: counts.completed,
|
||||
overdue: counts.overdue,
|
||||
skipped: counts.skipped,
|
||||
cancelled: counts.cancelled,
|
||||
percent,
|
||||
state,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ───────── کمکی ───────── */
|
||||
|
||||
private range(q: { from?: string; to?: string }, defaultDays: number) {
|
||||
const to = q.to || tehranDayKey();
|
||||
let from = q.from || addDays(to, -defaultDays);
|
||||
if (from > to) from = to;
|
||||
if (dayDiff(from, to) > 400) from = addDays(to, -400);
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
private async viewUsers(scope: RoutineScope, q: RoutineRangeQueryDto) {
|
||||
if (q.view === 'team') return (await this.teamUsers(scope, q)).map((u) => u.id);
|
||||
const userId = q.userId || scope.userId;
|
||||
if (!canView(scope, userId)) throw new ForbiddenException('دسترسی ندارید');
|
||||
return [userId];
|
||||
}
|
||||
|
||||
async teamUsers(scope: RoutineScope, q: { userId?: string; teamId?: string }) {
|
||||
assertManager(scope);
|
||||
let ids = scope.manageableUserIds;
|
||||
if (q.teamId) {
|
||||
const members = await this.prisma.teamMember.findMany({
|
||||
where: { teamId: q.teamId, team: { organizationId: scope.organizationId } },
|
||||
select: { userId: true },
|
||||
});
|
||||
const teamIds = members.map((m) => m.userId);
|
||||
ids = ids ? ids.filter((id) => teamIds.includes(id)) : teamIds;
|
||||
}
|
||||
if (q.userId) {
|
||||
ids = ids ? ids.filter((id) => id === q.userId) : [q.userId];
|
||||
}
|
||||
const rows = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
organizationId: scope.organizationId,
|
||||
isActive: true,
|
||||
user: { isActive: true },
|
||||
...(ids ? { userId: { in: ids } } : {}),
|
||||
},
|
||||
select: { user: { select: { id: true, fullName: true } } },
|
||||
orderBy: { user: { fullName: 'asc' } },
|
||||
});
|
||||
return rows.map((r) => r.user);
|
||||
}
|
||||
|
||||
private fetch(
|
||||
organizationId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
q: RoutineRangeQueryDto,
|
||||
userIds: string[],
|
||||
): Promise<StatTask[]> {
|
||||
return this.prisma.routineTask.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
assigneeId: { in: userIds },
|
||||
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
|
||||
...(q.categoryId ? { categoryId: q.categoryId } : {}),
|
||||
...(q.source ? { source: q.source } : {}),
|
||||
...(q.status ? { status: q.status } : {}),
|
||||
},
|
||||
select: statSelect,
|
||||
});
|
||||
}
|
||||
|
||||
private statusBreakdown(tasks: StatTask[]): RoutineStatusBreakdownDto & { notDone: number } {
|
||||
const c = emptyCounts();
|
||||
for (const t of tasks) addTo(c, t.status);
|
||||
return {
|
||||
completed: c.completed,
|
||||
notDone: c.pending + c.overdue,
|
||||
overdue: c.overdue,
|
||||
skipped: c.skipped,
|
||||
cancelled: c.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
private periodTotals(tasks: StatTask[], from: string, to: string): RoutinePeriodTotalsDto {
|
||||
const c = emptyCounts();
|
||||
for (const t of tasks) addTo(c, t.status);
|
||||
const days = this.summarizeDays(tasks, from, to);
|
||||
const percents = days.map((d) => d.percent).filter((p): p is number => p != null);
|
||||
return {
|
||||
total: effectiveTotal(c),
|
||||
completed: c.completed,
|
||||
notDone: c.pending + c.overdue,
|
||||
overdue: c.overdue,
|
||||
percent: percentOf(c),
|
||||
completeDays: days.filter((d) => d.state === 'complete').length,
|
||||
partialDays: days.filter((d) => d.state === 'partial').length,
|
||||
missedDays: days.filter((d) => d.state === 'missed').length,
|
||||
avgPercent: percents.length
|
||||
? Math.round(percents.reduce((s, p) => s + p, 0) / percents.length)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async aggregate(organizationId: string, tasks: StatTask[], from: string, to: string) {
|
||||
const counts = emptyCounts();
|
||||
for (const t of tasks) addTo(counts, t.status);
|
||||
const days = this.summarizeDays(tasks, from, to);
|
||||
const trend: RoutineTrendPointDto[] = days.map((d) => ({
|
||||
date: d.date,
|
||||
total: effectiveTotal(d.counts),
|
||||
completed: d.counts.completed,
|
||||
percent: d.percent,
|
||||
}));
|
||||
|
||||
const catCounts = new Map<string | null, Counts>();
|
||||
for (const t of tasks) {
|
||||
const c = catCounts.get(t.categoryId) ?? emptyCounts();
|
||||
addTo(c, t.status);
|
||||
catCounts.set(t.categoryId, c);
|
||||
}
|
||||
const catIds = [...catCounts.keys()].filter((id): id is string => !!id);
|
||||
const cats = catIds.length
|
||||
? await this.prisma.routineCategory.findMany({
|
||||
where: { organizationId, id: { in: catIds } },
|
||||
})
|
||||
: [];
|
||||
const byCategory: RoutineCategoryStatDto[] = [...catCounts.entries()]
|
||||
.map(([id, c]) => {
|
||||
const cat = cats.find((x) => x.id === id);
|
||||
return {
|
||||
categoryId: id,
|
||||
name: cat?.name ?? 'بدون دسته',
|
||||
color: cat?.color ?? '#94a3b8',
|
||||
total: effectiveTotal(c),
|
||||
completed: c.completed,
|
||||
percent: percentOf(c),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
return {
|
||||
counts,
|
||||
effTotal: effectiveTotal(counts),
|
||||
status: this.statusBreakdown(tasks),
|
||||
trend,
|
||||
byCategory,
|
||||
days: {
|
||||
complete: days.filter((d) => d.state === 'complete').length,
|
||||
partial: days.filter((d) => d.state === 'partial').length,
|
||||
missed: days.filter((d) => d.state === 'missed').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async memberStats(
|
||||
organizationId: string,
|
||||
users: Array<{ id: string; fullName: string }>,
|
||||
tasks: StatTask[],
|
||||
): Promise<RoutineMemberStatDto[]> {
|
||||
const streaks = await this.streaks(organizationId, users.map((u) => u.id));
|
||||
return users.map((user) => {
|
||||
const own = tasks.filter((t) => t.assigneeId === user.id);
|
||||
const c = emptyCounts();
|
||||
for (const t of own) addTo(c, t.status);
|
||||
return {
|
||||
user,
|
||||
total: effectiveTotal(c),
|
||||
completed: c.completed,
|
||||
overdue: c.overdue,
|
||||
skipped: c.skipped,
|
||||
percent: percentOf(c),
|
||||
streak: streaks.get(user.id) ?? 0,
|
||||
avgCompletionMinutes: this.avgCompletion(own),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** میانگین فاصله انجام از زمان برنامه (روتین: ساعت برنامه، بقیه: زمان ثبت) */
|
||||
private avgCompletion(tasks: StatTask[]): number | null {
|
||||
const values: number[] = [];
|
||||
for (const t of tasks) {
|
||||
if (t.status !== 'completed' || !t.completedAt) continue;
|
||||
const ref = t.source === 'routine' && t.dueAt ? t.dueAt : t.createdAt;
|
||||
values.push(Math.max(0, (t.completedAt.getTime() - ref.getTime()) / 60_000));
|
||||
}
|
||||
if (!values.length) return null;
|
||||
return Math.round(values.reduce((s, v) => s + v, 0) / values.length);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user