Files
workshop-manager/src/services/weather.ts
Froidefond Julien 7be296231c
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
feat: add weather trend chart showing indicator averages over time
Adds a collapsible SVG line graph on weather session pages displaying
the evolution of all 4 indicators (Performance, Moral, Flux, Création
de valeur) across sessions, with per-session average scores, hover
tooltips, and a marker on the current session.

Also fixes pre-existing lint errors: non-null assertion on optional
chain in Header and eslint-disable for intentional hydration pattern
in ThemeToggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:45:19 +01:00

412 lines
12 KiB
TypeScript

import { prisma } from '@/services/database';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import { createSessionPermissionChecks } from '@/services/session-permissions';
import { createShareAndEventHandlers } from '@/services/session-share-events';
import {
mergeSessionsByUserId,
fetchTeamCollaboratorSessions,
getSessionByIdGeneric,
} from '@/services/session-queries';
import { getWeekBounds } from '@/lib/date-utils';
import { getEmojiScore } from '@/lib/weather-utils';
import type { ShareRole } from '@prisma/client';
export type WeatherHistoryPoint = {
sessionId: string;
title: string;
date: Date;
performance: number | null;
moral: number | null;
flux: number | null;
valueCreation: number | null;
};
const weatherInclude = {
user: { select: { id: true, name: true, email: true } },
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
_count: { select: { entries: true } },
};
// ============================================
// Weather Session CRUD
// ============================================
export async function getWeatherSessionsByUserId(userId: string) {
return mergeSessionsByUserId(
(uid) =>
prisma.weatherSession.findMany({
where: { userId: uid },
include: weatherInclude,
orderBy: { updatedAt: 'desc' },
}),
(uid) =>
prisma.weatherSessionShare.findMany({
where: { userId: uid },
include: { session: { include: weatherInclude } },
}),
userId
);
}
/** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
return fetchTeamCollaboratorSessions(
(teamMemberIds, uid) =>
prisma.weatherSession.findMany({
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
include: weatherInclude,
orderBy: { updatedAt: 'desc' },
}),
getTeamMemberIdsForAdminTeams,
userId
);
}
const weatherByIdInclude = {
user: { select: { id: true, name: true, email: true } },
entries: {
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'asc' } as const,
},
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
};
export async function getWeatherSessionById(sessionId: string, userId: string) {
return getSessionByIdGeneric(
sessionId,
userId,
(sid, uid) =>
prisma.weatherSession.findFirst({
where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
include: weatherByIdInclude,
}),
(sid) => prisma.weatherSession.findUnique({ where: { id: sid }, include: weatherByIdInclude })
);
}
const weatherPermissions = createSessionPermissionChecks(prisma.weatherSession);
const weatherShareEvents = createShareAndEventHandlers<
'ENTRY_CREATED' | 'ENTRY_UPDATED' | 'ENTRY_DELETED' | 'SESSION_UPDATED'
>(
prisma.weatherSession,
prisma.weatherSessionShare,
prisma.weatherSessionEvent,
weatherPermissions.canAccess
);
export const canAccessWeatherSession = weatherPermissions.canAccess;
export const canEditWeatherSession = weatherPermissions.canEdit;
export const canDeleteWeatherSession = weatherPermissions.canDelete;
export async function createWeatherSession(userId: string, data: { title: string; date?: Date }) {
return prisma.weatherSession.create({
data: {
...data,
date: data.date || new Date(),
userId,
},
include: {
entries: {
include: {
user: { select: { id: true, name: true, email: true } },
},
},
},
});
}
export async function updateWeatherSession(
sessionId: string,
userId: string,
data: { title?: string; date?: Date }
) {
if (!(await canEditWeatherSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weatherSession.updateMany({
where: { id: sessionId },
data,
});
}
export async function deleteWeatherSession(sessionId: string, userId: string) {
if (!(await canDeleteWeatherSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.weatherSession.deleteMany({
where: { id: sessionId },
});
}
// ============================================
// Weather Entry CRUD
// ============================================
export async function getWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.findUnique({
where: {
sessionId_userId: { sessionId, userId },
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function createOrUpdateWeatherEntry(
sessionId: string,
userId: string,
data: {
performanceEmoji?: string | null;
moralEmoji?: string | null;
fluxEmoji?: string | null;
valueCreationEmoji?: string | null;
notes?: string | null;
}
) {
return prisma.weatherEntry.upsert({
where: {
sessionId_userId: { sessionId, userId },
},
update: data,
create: {
sessionId,
userId,
...data,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function deleteWeatherEntry(sessionId: string, userId: string) {
return prisma.weatherEntry.deleteMany({
where: { sessionId, userId },
});
}
// Returns the most recent WeatherEntry per userId from any session BEFORE sessionDate,
// excluding the current session. Returned as a map userId → entry.
export async function getPreviousWeatherEntriesForUsers(
excludeSessionId: string,
sessionDate: Date,
userIds: string[]
): Promise<
Map<
string,
{
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
}
>
> {
if (userIds.length === 0) return new Map();
const entries = await prisma.weatherEntry.findMany({
where: {
userId: { in: userIds },
sessionId: { not: excludeSessionId },
session: { date: { lt: sessionDate } },
},
select: {
userId: true,
performanceEmoji: true,
moralEmoji: true,
fluxEmoji: true,
valueCreationEmoji: true,
session: { select: { date: true } },
},
});
// Sort by session.date desc (Prisma orderBy on relation is unreliable with SQLite)
entries.sort((a, b) => {
const dateA = a.session.date.getTime();
const dateB = b.session.date.getTime();
if (dateB !== dateA) return dateB - dateA; // most recent first
return a.userId.localeCompare(b.userId);
});
// For each user, use the most recent previous value PER AXIS (fallback if latest session has null)
const map = new Map<
string,
{
performanceEmoji: string | null;
moralEmoji: string | null;
fluxEmoji: string | null;
valueCreationEmoji: string | null;
}
>();
for (const entry of entries) {
const existing = map.get(entry.userId);
const base = existing ?? {
performanceEmoji: null as string | null,
moralEmoji: null as string | null,
fluxEmoji: null as string | null,
valueCreationEmoji: null as string | null,
};
if (!existing) map.set(entry.userId, base);
if (base.performanceEmoji == null && entry.performanceEmoji != null)
base.performanceEmoji = entry.performanceEmoji;
if (base.moralEmoji == null && entry.moralEmoji != null) base.moralEmoji = entry.moralEmoji;
if (base.fluxEmoji == null && entry.fluxEmoji != null) base.fluxEmoji = entry.fluxEmoji;
if (base.valueCreationEmoji == null && entry.valueCreationEmoji != null)
base.valueCreationEmoji = entry.valueCreationEmoji;
}
return map;
}
// ============================================
// Session Sharing
// ============================================
export const shareWeatherSession = weatherShareEvents.share;
export async function shareWeatherSessionToTeam(
sessionId: string,
ownerId: string,
teamId: string,
role: ShareRole = 'EDITOR'
) {
// Verify owner
const session = await prisma.weatherSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
// Max 1 météo par équipe par semaine
const teamMembers = await prisma.teamMember.findMany({
where: { teamId },
select: { userId: true },
});
const teamMemberIds = teamMembers.map((tm) => tm.userId);
if (teamMemberIds.length > 0) {
const { start: weekStart, end: weekEnd } = getWeekBounds(session.date);
const existingCount = await prisma.weatherSession.count({
where: {
id: { not: sessionId },
date: { gte: weekStart, lte: weekEnd },
shares: {
some: { userId: { in: teamMemberIds } },
},
},
});
if (existingCount > 0) {
throw new Error('Cette équipe a déjà une météo pour cette semaine');
}
}
// Get team members (full)
const teamMembersFull = await prisma.teamMember.findMany({
where: { teamId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
if (teamMembersFull.length === 0) {
throw new Error('Team has no members');
}
// Share with all team members (except owner)
const shares = await Promise.all(
teamMembersFull
.filter((tm) => tm.userId !== ownerId) // Don't share with yourself
.map((tm) =>
prisma.weatherSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: tm.userId },
},
update: { role },
create: {
sessionId,
userId: tm.userId,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
})
)
);
return shares;
}
export const removeWeatherShare = weatherShareEvents.removeShare;
export const getWeatherSessionShares = weatherShareEvents.getShares;
// ============================================
// Session Events (for real-time sync)
// ============================================
export type WeatherSessionEventType =
| 'ENTRY_CREATED'
| 'ENTRY_UPDATED'
| 'ENTRY_DELETED'
| 'SESSION_UPDATED';
export const createWeatherSessionEvent = weatherShareEvents.createEvent;
export const getWeatherSessionEvents = weatherShareEvents.getEvents;
export const getLatestWeatherEventTimestamp = weatherShareEvents.getLatestEventTimestamp;
// ============================================
// Weather History (for trend chart)
// ============================================
function avgScore(emojis: (string | null)[]): number | null {
const scores = emojis.map(getEmojiScore).filter((s): s is number => s !== null);
if (scores.length === 0) return null;
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
}
export async function getWeatherSessionsHistory(userId: string): Promise<WeatherHistoryPoint[]> {
const entrySelect = {
performanceEmoji: true,
moralEmoji: true,
fluxEmoji: true,
valueCreationEmoji: true,
} as const;
const [ownSessions, sharedRaw] = await Promise.all([
prisma.weatherSession.findMany({
where: { userId },
select: { id: true, title: true, date: true, entries: { select: entrySelect } },
}),
prisma.weatherSessionShare.findMany({
where: { userId },
select: {
session: {
select: { id: true, title: true, date: true, entries: { select: entrySelect } },
},
},
}),
]);
const seen = new Set<string>();
const all: { id: string; title: string; date: Date; entries: typeof ownSessions[0]['entries'] }[] = [];
for (const s of [...ownSessions, ...sharedRaw.map((r) => r.session)]) {
if (!seen.has(s.id)) {
seen.add(s.id);
all.push(s);
}
}
all.sort((a, b) => a.date.getTime() - b.date.getTime());
return all.map((s) => ({
sessionId: s.id,
title: s.title,
date: s.date,
performance: avgScore(s.entries.map((e) => e.performanceEmoji)),
moral: avgScore(s.entries.map((e) => e.moralEmoji)),
flux: avgScore(s.entries.map((e) => e.fluxEmoji)),
valueCreation: avgScore(s.entries.map((e) => e.valueCreationEmoji)),
}));
}