diff --git a/pages/api/index.ts b/pages/api/index.ts index cfb3e00..b3c44c3 100644 --- a/pages/api/index.ts +++ b/pages/api/index.ts @@ -147,9 +147,17 @@ export default async function handler(req, res) { res.status(200).json(events); case "getPublisherInfo": - let pubs = await filterPublishers("id,firstName,lastName,email".split(","), "", null, req.query.assignments || true, req.query.availabilities || true, false, req.query.id); - res.status(200).json(pubs[0]); + //let pubs = await filterPublishers("id,firstName,lastName,email".split(","), "", null, req.query.assignments || true, req.query.availabilities || true, false, req.query.id); + let pubs = await dataHelper.filterPublishersNew("id,firstName,lastName,email,isActive,assignments,availabilities", day, false, true, false, true, false, req.query.id); + res.status(200).json(pubs[0] || {}); break; + case "filterPublishersNew": + let includeOldAvailabilities = common.parseBool(req.query.includeOldAvailabilities); + let results = await filterPublishersNew_Available(req.query.select, day, + common.parseBool(req.query.isExactTime), common.parseBool(req.query.isForTheMonth), true, includeOldAvailabilities, req.query.id); + res.status(200).json(results); + break; + case "getMonthlyStatistics": let allpubs = await getMonthlyStatistics("id,firstName,lastName,email", day); res.status(200).json(allpubs); @@ -168,12 +176,6 @@ export default async function handler(req, res) { //!console.log("publishers: (" + publishers.length + ") " + JSON.stringify(publishers.map(pub => pub.firstName + " " + pub.lastName))); res.status(200).json(publishers); break; - case "filterPublishersNew": - let includeOldAvailabilities = common.parseBool(req.query.includeOldAvailabilities); - let results = await filterPublishersNew_Available(req.query.select, day, - common.parseBool(req.query.isExactTime), common.parseBool(req.query.isForTheMonth), true, includeOldAvailabilities); - res.status(200).json(results); - break; // find publisher by full name or email case "findPublisher": diff --git a/pages/api/shiftgenerate.ts b/pages/api/shiftgenerate.ts index f144167..48c2376 100644 --- a/pages/api/shiftgenerate.ts +++ b/pages/api/shiftgenerate.ts @@ -3,6 +3,8 @@ import axiosServer from '../../src/axiosServer'; import { getToken } from "next-auth/jwt"; +import { set, format, addDays } from 'date-fns'; + import type { NextApiRequest, NextApiResponse } from "next"; import { Prisma, PrismaClient, DayOfWeek, Publisher, Shift } from "@prisma/client"; import { levenshteinEditDistance } from "levenshtein-edit-distance"; @@ -465,7 +467,29 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { // ### COPIED TO shift api (++) ### + + +let scheduledPubsPerDayAndWeek = {}; +// Function to flatten the registry +// Function to update the registry +function updateRegistry(publisherId, day, weekNr) { + // Registry schema: {day: {weekNr: [publisherIds]}} + const dayKey = common.getISODateOnly(day); + if (!scheduledPubsPerDayAndWeek[dayKey]) { + scheduledPubsPerDayAndWeek[dayKey] = {}; + } + if (!scheduledPubsPerDayAndWeek[dayKey][weekNr]) { + scheduledPubsPerDayAndWeek[dayKey][weekNr] = []; + } + scheduledPubsPerDayAndWeek[dayKey][weekNr].push(publisherId); +} +function flattenRegistry(dayKey) { + const weekEntries = scheduledPubsPerDayAndWeek[dayKey] || {}; + return Object.values(weekEntries).flat(); +} + async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, autoFill = false, forDay) { + let missingPublishers = []; let publishersWithChangedPref = []; @@ -494,6 +518,7 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto let dayNr = 1; let weekNr = 1; + if (forDay) { day = monthInfo.date; endDate.setDate(monthInfo.date.getDate() + 1); @@ -503,6 +528,8 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto let publishersThisWeek = []; + + // 0. generate shifts and assign publishers from the previous month if still available while (day < endDate) { let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false); @@ -542,8 +569,8 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto if (shiftLastMonthSameDay) { for (let assignment of shiftLastMonthSameDay.assignments) { - let publisher = assignment.publisher; - console.log("found publisher from last month: " + publisher.firstName + " " + publisher.lastName); + let publisher = assignment.originalPublisher ?? assignment.publisher; + console.log("found publisher from last month: " + publisher.firstName + " " + publisher.lastName + assignment.originalPublisher ? " (original)" : ""); let availability = await FindPublisherAvailability(publisher.id, shiftStart, shiftEnd, dayOfWeekEnum, weekNr); console.log("availability " + availability?.id + ": " + common.getDateFormattedShort(availability?.startTime) + " " + common.getTimeFormatted(availability?.startTime) + " - " + common.getTimeFormatted(availability?.endTime)); @@ -554,28 +581,17 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto isWithTransport: availability.isWithTransportIn || availability.isWithTransportOut }); publishersThisWeek.push(publisher.id); + updateRegistry(publisher.id, day, weekNr); } } } - let publishersNeeded = event.numberOfPublishers - shiftAssignments.length; //ToDo: check if getAvailablePublishersForShift is working correctly. It seems not to! let availablePublishers = await getAvailablePublishersForShiftNew(shiftStart, shiftEnd, availablePubsForTheDay, publishersThisWeek); console.log("shift " + __shiftName + " needs " + publishersNeeded + " publishers, available: " + availablePublishers.length + " for the day: " + availablePubsForTheDay.length); - // Prioritize publishers with minimal availability - // SKIP ADDING PUBLISHERS FOR NOW - // availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount); - - // for (let i = 0; i < publishersNeeded; i++) { - // if (availablePublishers[i]) { - // shiftAssignments.push({ publisherId: availablePublishers[i].id }); - // publishersThisWeek.push(availablePublishers[i].id); - // } - // } - const createdShift = await prisma.shift.create({ data: { startTime: shiftStart, @@ -634,7 +650,7 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto let publishersToday = []; - // 2. First pass - prioritize shifts with transport where it is needed + // Second pass - prioritize shifts with transport where it is needed console.log(" second pass - fix transports " + monthInfo.monthName + " " + monthInfo.year); day = new Date(monthInfo.firstMonday); dayNr = 1; @@ -646,8 +662,6 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false); let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day)); - //let publishersToday = shifts.flatMap(s => s.assignments.map(a => a.publisher.id)); - //get all publishers assigned for the day from the database let publishersToday = await prisma.assignment.findMany({ where: { shift: { @@ -662,7 +676,6 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto }, }).then((assignments) => assignments.map(a => a.publisherId)); - let transportShifts = shifts.filter(s => s.requiresTransport); transportShifts[0].transportIn = true; if (transportShifts.length > 1) { @@ -671,7 +684,6 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto // if there are no transport yet: // transportShifts.forEach(async shift => { for (const shift of transportShifts) { - //todo: replace that with transport check let publishersNeeded = event.numberOfPublishers - shift.assignments.length; let transportCapable = shift.assignments.filter(a => a.isWithTransport); let tramsportCapableMen = transportCapable.filter(a => a.publisher.isMale); @@ -693,8 +705,8 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto }); availablePublishers = await FilterInappropriatePublishers([...availablePublishers], publishersToday, shift); // rank publishers based on different factors - let rankedPublishersOld = await RankPublishersForShift([...availablePublishers]) - let rankedPublishers = await RankPublishersForShiftWeighted([...availablePublishers]) + // let rankedPublishersOld = await RankPublishersForShift([...availablePublishers]) + let rankedPublishers = await RankPublishersForShiftWeighted([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr); if (rankedPublishers.length > 0) { const newAssignment = await prisma.assignment.create({ data: { @@ -716,8 +728,8 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto shift.assignments.push(newAssignment); publishersToday.push(rankedPublishers[0].id); + updateRegistry(rankedPublishers[0].id, day, weekNr); } - } } } @@ -725,10 +737,8 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto day.setDate(day.getDate() + 1); } - - - // 3. next passes - fill the rest of the shifts - let goal = 1; // 4 to temporary skip + // Fill the rest of the shifts + let goal = 1; while (goal <= 4) { console.log("#".repeat(50)); console.log("Filling shifts with " + goal + " publishers " + monthInfo.monthName + " " + monthInfo.year); @@ -741,7 +751,6 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto if (event) { let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false); let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day)); - // let publishersToday = shifts.flatMap(s => s.assignments.map(a => a.publisher?.id)); let publishersToday = await prisma.assignment.findMany({ where: { shift: { @@ -760,14 +769,15 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto console.log("" + day.toLocaleDateString() + " " + shiftsToFill.length + " shifts with less than " + goal + " publishers"); for (const shift of shiftsToFill) { + console.log("Filling shift " + shift.name + " with " + goal + " publishers"); let publishersNeeded = event.numberOfPublishers - shift.assignments.length; if (publishersNeeded > 0 && shift.assignments.length < goal) { let availablePubsForTheShift = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', shift.startTime, true, false, false, true, false); let availablePublishers = await FilterInappropriatePublishers([...availablePubsForTheShift], publishersToday, shift); - - shift.availablePublishers = availablePublishers.length; - let rankedPublishers = await RankPublishersForShift([...availablePublishers]) + let rankedPublishers; + rankedPublishers = await RankPublishersForShiftOld([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr); + //rankedPublishers = await RankPublishersForShiftWeighted([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr); if (rankedPublishers.length == 0) { console.log("No available publishers for shift " + shift.name); } else if (rankedPublishers.length > 0) { @@ -790,8 +800,8 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto }); shift.assignments.push(newAssignment); publishersToday.push(rankedPublishers[0].id); + updateRegistry(rankedPublishers[0].id, day, weekNr); - //check if publisher.familyMembers are also available and add them to the shift. ToDo: test case let familyMembers = availablePubsForTheShift.filter(p => p.familyHeadId && p.familyHeadId === rankedPublishers[0].familyHeadId); if (familyMembers.length > 0) { familyMembers.forEach(async familyMember => { @@ -815,18 +825,18 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto }); shift.assignments.push(newAssignment); publishersToday.push(familyMember.id); + updateRegistry(familyMember.id, day, weekNr); } }); } - } } - }; + } } day.setDate(day.getDate() + 1); } - goal += 1 + goal += 1; } if (!forDay) { @@ -845,11 +855,9 @@ async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, auto async function FilterInappropriatePublishers(availablePublishers, pubsToExclude, shift) { //ToDo: Optimization: store number of publishers, so we process the shifts from least to most available publishers later. let goodPublishers = availablePublishers.filter(p => { - const isNotAssigned = !shift.assignments.some(a => a.publisher?.id === p.id); const isNotAssignedToday = !pubsToExclude.includes(p.id); const isAssignedEnough = p.currentMonthAssignments >= p.desiredShiftsPerMonth; - //if (isAssignedEnough) console.log(p.firstName + " " + p.lastName + " is assigned enough: " + p.currentMonthAssignments + " >= " + p.desiredShiftsPerMonth); return isNotAssigned && isNotAssignedToday && !isAssignedEnough; }); return goodPublishers; @@ -865,7 +873,7 @@ async function FilterInappropriatePublishers(availablePublishers, pubsToExclude, // 6. Idealy noone should be more than once a week. disqualify publishers already on a shift this week. only assign them if there are no other options and we have less than 3 publishers on a specific shift. //sort publishers to rank the best option for the current shift assignment -async function RankPublishersForShift(publishers) { +async function RankPublishersForShiftOld(publishers, scheduledPubsPerDayAndWeek, currentDay) { publishers.forEach(p => { p.DesiredMinusCurrent = p.desiredShiftsPerMonth - p.currentMonthAssignments; }); @@ -874,22 +882,54 @@ async function RankPublishersForShift(publishers) { // males first (descending) if (a.isMale && !b.isMale) return -1; - // desired completion (normalized 0%=0 - 100%=1) ; lower first - const desiredCompletion = a.currentMonthAssignments / a.desiredShiftsPerMonth - b.currentMonthAssignments / b.desiredShiftsPerMonth; - //console.log(a.firstName + " " + a.lastName + " desiredCompletion: " + desiredCompletion, a.currentMonthAssignments, "/", a.desiredShiftsPerMonth); - if (desiredCompletion !== 0) return desiredCompletion; - // const desiredDifference = b.DesiredMinusCurrent - a.DesiredMinusCurrent; - // if (desiredDifference !== 0) return desiredDifference; + // desired completion (normalized 0%=0 - 100%=1); lower first const desiredCompletionA = a.currentMonthAssignments / a.desiredShiftsPerMonth; const desiredCompletionB = b.currentMonthAssignments / b.desiredShiftsPerMonth; - console.log(`${a.firstName} ${a.lastName} desiredCompletion: ${desiredCompletionA}, ${a.currentMonthAssignments} / ${a.desiredShiftsPerMonth}`); - // console.log(`${b.firstName} ${b.lastName} desiredCompletion: ${desiredCompletionB}, ${b.currentMonthAssignments} / ${b.desiredShiftsPerMonth}`); + + let adjustedCompletionA = desiredCompletionA; + let adjustedCompletionB = desiredCompletionB; + + // Apply penalties based on proximity to current day + for (let i = 1; i <= 6; i++) { + const previousDayKey = common.getISODateOnly(addDays(currentDay, -i)); + const nextDayKey = common.getISODateOnly(addDays(currentDay, i)); + + const penalty = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95][i - 1]; // Penalties for +-1 to +-6 days + + // if (scheduledPubsPerDayAndWeek[previousDayKey]?.some(pubId => pubId === a.id)) { + // adjustedCompletionA *= penalty; + // } + // if (scheduledPubsPerDayAndWeek[previousDayKey]?.some(pubId => pubId === b.id)) { + // adjustedCompletionB *= penalty; + // } + // if (scheduledPubsPerDayAndWeek[nextDayKey]?.some(pubId => pubId === a.id)) { + // adjustedCompletionA *= penalty; + // } + // if (scheduledPubsPerDayAndWeek[nextDayKey]?.some(pubId => pubId === b.id)) { + // adjustedCompletionB *= penalty; + // } + if (flattenRegistry(previousDayKey).includes(a.id)) { + adjustedCompletionA *= penalty; + } + if (flattenRegistry(previousDayKey).includes(b.id)) { + adjustedCompletionB *= penalty; + } + if (flattenRegistry(nextDayKey).includes(a.id)) { + adjustedCompletionA *= penalty; + } + if (flattenRegistry(nextDayKey).includes(b.id)) { + adjustedCompletionB *= penalty; + } + + } + + const desiredCompletionDiff = adjustedCompletionA - adjustedCompletionB; + if (desiredCompletionDiff !== 0) return desiredCompletionDiff; // less available first (ascending) const availabilityDifference = a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount; if (availabilityDifference !== 0) return availabilityDifference; - // less assigned first (ascending) return a.currentMonthAssignments - b.currentMonthAssignments; }); @@ -897,8 +937,9 @@ async function RankPublishersForShift(publishers) { return ranked; } + // ToDo: add negative weights for currentweekAssignments, so we avoid assigning the same publishers multiple times in a week. having in mind the days difference between shifts. -async function RankPublishersForShiftWeighted(publishers) { +async function RankPublishersForShiftWeighted(publishers, scheduledPubsPerDayAndWeek, currentDay, currentWeekNr) { // Define weights for each criterion const weights = { gender: 2, @@ -919,19 +960,33 @@ async function RankPublishersForShiftWeighted(publishers) { p.desiredCompletion = p.currentMonthAssignments / p.desiredShiftsPerMonth; }); - let ranked = publishers.sort((a, b) => { - // Calculate weighted score for each publisher - const scoreA = (a.isMale ? weights.gender : 0) - - (a.desiredCompletion * weights.desiredCompletion) + - ((1 - a.currentMonthAvailabilityHoursCount / 24) * weights.availability) + - (a.currentMonthAssignments * weights.lastMonthCompletion) - - (a.currentMonthAssignments * weights.currentAssignments); + const calculateScore = (p) => { + let score = (p.isMale ? weights.gender : 0) - + (p.desiredCompletion * weights.desiredCompletion) + + ((1 - p.currentMonthAvailabilityHoursCount / 24) * weights.availability) + + (p.currentMonthAssignments * weights.lastMonthCompletion) - + (p.currentMonthAssignments * weights.currentAssignments); - const scoreB = (b.isMale ? weights.gender : 0) - - (b.desiredCompletion * weights.desiredCompletion) + - ((1 - b.currentMonthAvailabilityHoursCount / 24) * weights.availability) + - (b.currentMonthAssignments * weights.lastMonthCompletion) - - (b.currentMonthAssignments * weights.currentAssignments); + // Apply penalties based on proximity to current day + for (let i = 1; i <= 6; i++) { + const previousDayKey = common.getISODateOnly(addDays(currentDay, -i)); + const nextDayKey = common.getISODateOnly(addDays(currentDay, i)); + + const penalty = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95][i - 1]; // Penalties for +-1 to +-6 days + + if (flattenRegistry(previousDayKey).includes(p.id)) { + score *= penalty; + } + if (flattenRegistry(nextDayKey).includes(p.id)) { + score *= penalty; + } + } + return score; + }; + + let ranked = publishers.sort((a, b) => { + const scoreA = calculateScore(a); + const scoreB = calculateScore(b); return scoreB - scoreA; // Sort descending by score }); @@ -943,6 +998,7 @@ async function RankPublishersForShiftWeighted(publishers) { + async function DeleteShiftsForMonth(monthInfo) { try { const prisma = common.getPrismaClient(); diff --git a/pages/cart/calendar/index.tsx b/pages/cart/calendar/index.tsx index c66840e..4badd31 100644 --- a/pages/cart/calendar/index.tsx +++ b/pages/cart/calendar/index.tsx @@ -362,7 +362,6 @@ export default function CalendarPage({ initialEvents, initialShifts }) { const newAssignment = { publisher: { connect: { id: publisher.id } }, shift: { connect: { id: shiftId } }, - isActive: true, isConfirmed: true }; const { data } = await axiosInstance.post("/api/data/assignments", newAssignment); @@ -842,7 +841,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) { return common.getStartOfWeek(value) <= shiftDate && shiftDate <= common.getEndOfWeek(value); }); const dayShifts = weekShifts.map(shift => { - const isAvailable = publisher.availabilities.some(avail => + const isAvailable = publisher.availabilities?.some(avail => avail.startTime <= shift.startTime && avail.endTime >= shift.endTime ); let color = isAvailable ? getColorForShift(shift) : 'bg-gray-300'; @@ -863,7 +862,7 @@ export default function CalendarPage({ initialEvents, initialShifts }) { const hasAssignment = (shiftId) => { // return publisher.assignments.some(ass => ass.shift.id == shiftId); - return publisher.assignments.some(ass => { + return publisher.assignments?.some(ass => { console.log(`Comparing: ${ass.shift.id} to ${shiftId}: ${ass.shift.id === shiftId}`); return ass.shift.id === shiftId; }); diff --git a/pages/cart/calendar/schedule.tsx b/pages/cart/calendar/schedule.tsx index f7c952c..90f8d43 100644 --- a/pages/cart/calendar/schedule.tsx +++ b/pages/cart/calendar/schedule.tsx @@ -35,9 +35,9 @@ const SchedulePage = () => { }, []); // Empty dependency array means this effect runs once on component mount // temporary alert for the users - useEffect(() => { - alert("Мили братя, искаме само да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'"); - }, []); + // useEffect(() => { + // alert("Мили братя, искаме само да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'"); + // }, []); return ( diff --git a/pages/cart/publishers/myschedule.tsx b/pages/cart/publishers/myschedule.tsx index 7b0059b..0196358 100644 --- a/pages/cart/publishers/myschedule.tsx +++ b/pages/cart/publishers/myschedule.tsx @@ -28,9 +28,9 @@ export default function MySchedulePage({ assignments }) { const { data: session, status } = useSession(); // temporary alert for the users - useEffect(() => { - alert("Мили братя, искаме само да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'"); - }, []); + // useEffect(() => { + // alert("Мили братя, искаме само да ви напомним да ни изпратите вашите предпочитания за юни до 25-то число като използвате меню 'Възможности'. Ако имате проблем, моля пишете ни на 'specialnosvidetelstvanesofia@gmail.com'"); + // }, []); if (status === "loading") { return