@@ -71,21 +71,103 @@ function currentMonthKey(): string {
7171 return new Date ( ) . toISOString ( ) . slice ( 0 , 7 ) ; // 'YYYY-MM'
7272}
7373
74+ /** Les `n` derniers mois (clés 'YYYY-MM', mois courant inclus). */
75+ function recentMonthKeys ( n : number ) : Set < string > {
76+ const set = new Set < string > ( ) ;
77+ const now = new Date ( ) ;
78+ for ( let i = 0 ; i < Math . max ( 0 , n ) ; i ++ ) {
79+ const d = new Date ( Date . UTC ( now . getUTCFullYear ( ) , now . getUTCMonth ( ) - i , 1 ) ) ;
80+ set . add ( d . toISOString ( ) . slice ( 0 , 7 ) ) ;
81+ }
82+ return set ;
83+ }
84+
85+ /** Dernier mois de tirage par apprenant (studentId → 'YYYY-MM'). */
86+ async function lastDrawnMonthByStudent ( ) : Promise < Map < number , string > > {
87+ const rows = await db
88+ . select ( {
89+ studentId : coffeeDrawParticipants . studentId ,
90+ lastMonth : sql < string > `max(${ coffeeDraws . month } )` ,
91+ } )
92+ . from ( coffeeDrawParticipants )
93+ . innerJoin ( coffeeDraws , eq ( coffeeDraws . id , coffeeDrawParticipants . drawId ) )
94+ . groupBy ( coffeeDrawParticipants . studentId ) ;
95+ return new Map ( rows . map ( ( r ) => [ r . studentId , r . lastMonth ] ) ) ;
96+ }
97+
7498/**
75- * Tire au sort 9–10 apprenants éligibles et persiste le tirage (+ snapshot des
76- * participants). Le quota est aléatoire dans {9, 10}, borné par la taille du
77- * vivier. Phase de test : aucun message Discord n'est envoyé.
99+ * Sélection anti-répétition : privilégie les JAMAIS-tirés, puis les tirés hors
100+ * cooldown (les moins récents d'abord), et n'entame les « en cooldown » (tirés
101+ * dans les `cooldownMonths` derniers mois) qu'en dernier recours si le vivier
102+ * est trop petit pour atteindre `count`.
103+ */
104+ async function pickWithAntiRepeat ( {
105+ count,
106+ includeAlternants,
107+ excludeIds = [ ] ,
108+ cooldownMonths,
109+ } : {
110+ count : number ;
111+ includeAlternants : boolean ;
112+ excludeIds ?: number [ ] ;
113+ cooldownMonths : number ;
114+ } ) : Promise < EligibleStudent [ ] > {
115+ const pool = await getEligibleStudentsForCoffee ( { exclude : excludeIds , includeAlternants } ) ;
116+ const lastMonth = await lastDrawnMonthByStudent ( ) ;
117+ const recent = recentMonthKeys ( cooldownMonths ) ;
118+
119+ const never : EligibleStudent [ ] = [ ] ;
120+ const stale : { s : EligibleStudent ; m : string } [ ] = [ ] ;
121+ const cooling : { s : EligibleStudent ; m : string } [ ] = [ ] ;
122+ for ( const s of pool ) {
123+ const m = lastMonth . get ( s . id ) ;
124+ if ( ! m ) never . push ( s ) ;
125+ else if ( recent . has ( m ) ) cooling . push ( { s, m } ) ;
126+ else stale . push ( { s, m } ) ;
127+ }
128+
129+ const picked : EligibleStudent [ ] = [ ] ;
130+ const take = ( arr : EligibleStudent [ ] ) => {
131+ for ( const s of arr ) {
132+ if ( picked . length >= count ) break ;
133+ picked . push ( s ) ;
134+ }
135+ } ;
136+ take ( shuffle ( never ) ) ;
137+ take ( shuffle ( stale . map ( ( x ) => x . s ) ) ) ;
138+ // Relâchement : en cooldown, les moins récents d'abord.
139+ take ( cooling . sort ( ( a , b ) => a . m . localeCompare ( b . m ) ) . map ( ( x ) => x . s ) ) ;
140+ return picked . slice ( 0 , count ) ;
141+ }
142+
143+ /**
144+ * Tire au sort des apprenants éligibles (quota configurable, défaut aléatoire
145+ * 9–10) avec anti-répétition, et persiste le tirage (+ snapshot des
146+ * participants). Phase de test : aucun message Discord n'est envoyé.
78147 */
79148export async function createCoffeeDraw (
80- { includeAlternants = true } : { includeAlternants ?: boolean } = { } ,
149+ {
150+ includeAlternants = true ,
151+ quota,
152+ cooldownMonths = 3 ,
153+ } : { includeAlternants ?: boolean ; quota ?: number ; cooldownMonths ?: number } = { } ,
81154) : Promise < CoffeeDrawWithParticipants > {
82- const pool = await getEligibleStudentsForCoffee ( { includeAlternants } ) ;
83- const targetQuota = Math . random ( ) < 0.5 ? 9 : 10 ;
84- const picked = shuffle ( pool ) . slice ( 0 , Math . min ( targetQuota , pool . length ) ) ;
155+ const targetQuota = quota && quota > 0 ? quota : Math . random ( ) < 0.5 ? 9 : 10 ;
156+ const picked = await pickWithAntiRepeat ( {
157+ count : targetQuota ,
158+ includeAlternants,
159+ cooldownMonths,
160+ } ) ;
85161
86162 const [ draw ] = await db
87163 . insert ( coffeeDraws )
88- . values ( { month : currentMonthKey ( ) , quota : picked . length , includeAlternants, status : 'draft' } )
164+ . values ( {
165+ month : currentMonthKey ( ) ,
166+ quota : picked . length ,
167+ includeAlternants,
168+ cooldownMonths,
169+ status : 'draft' ,
170+ } )
89171 . returning ( ) ;
90172
91173 if ( picked . length > 0 ) {
@@ -139,20 +221,25 @@ export async function redrawParticipant(participantId: number): Promise<RedrawRe
139221 . where ( eq ( coffeeDrawParticipants . drawId , participant . drawId ) ) ;
140222 const excludeIds = current . map ( ( r ) => r . studentId ) ;
141223
142- // Réutilise le réglage alternants du tirage.
224+ // Réutilise les réglages du tirage (alternants + cooldown anti-répétition) .
143225 const [ draw0 ] = await db
144- . select ( { includeAlternants : coffeeDraws . includeAlternants } )
226+ . select ( {
227+ includeAlternants : coffeeDraws . includeAlternants ,
228+ cooldownMonths : coffeeDraws . cooldownMonths ,
229+ } )
145230 . from ( coffeeDraws )
146231 . where ( eq ( coffeeDraws . id , participant . drawId ) )
147232 . limit ( 1 ) ;
148233
149- const pool = await getEligibleStudentsForCoffee ( {
150- exclude : excludeIds ,
234+ const picked = await pickWithAntiRepeat ( {
235+ count : 1 ,
151236 includeAlternants : draw0 ?. includeAlternants ?? true ,
237+ excludeIds,
238+ cooldownMonths : draw0 ?. cooldownMonths ?? 3 ,
152239 } ) ;
153- if ( pool . length === 0 ) return { ok : false , reason : 'pool_exhausted' } ;
240+ if ( picked . length === 0 ) return { ok : false , reason : 'pool_exhausted' } ;
154241
155- const next = shuffle ( pool ) [ 0 ] ;
242+ const next = picked [ 0 ] ;
156243 await db
157244 . update ( coffeeDrawParticipants )
158245 . set ( {
0 commit comments