Skip to content

Commit 9dadbba

Browse files
makcimerrrclaude
andcommitted
feat(coffee): anti-répétition + quota configurable
- Quota configurable au lancement (input « Nombre », défaut 10, 1–50) au lieu du 9–10 aléatoire ; borné par le vivier. - Anti-répétition : cooldown de N mois (défaut 3, colonne coffee_draws. cooldown_months) — on évite les tirés récents et on privilégie les jamais-tirés, puis les moins récents ; relâchement si vivier trop petit. Le re-tirage individuel réutilise les mêmes réglages. - migration 0027. Indicateur affiché sur la carte. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent efa99e1 commit 9dadbba

6 files changed

Lines changed: 137 additions & 19 deletions

File tree

app/api/coffee-draws/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ export const POST = withErrorHandler(
2020
withAdmin(async (req) => {
2121
const body = await req.json().catch(() => ({}));
2222
const includeAlternants = body?.includeAlternants !== false; // défaut true
23-
const draw = await createCoffeeDraw({ includeAlternants });
23+
const rawQuota = Number(body?.quota);
24+
const quota =
25+
Number.isInteger(rawQuota) && rawQuota > 0 ? Math.min(rawQuota, 50) : undefined;
26+
const draw = await createCoffeeDraw({ includeAlternants, quota });
2427
return apiSuccess({ draw });
2528
}),
2629
);

components/dashboard/coffee-draw-button.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,32 @@ import { useRouter } from 'next/navigation';
55
import { toast } from 'sonner';
66
import { Button } from '@/components/ui/button';
77
import { Switch } from '@/components/ui/switch';
8+
import { Input } from '@/components/ui/input';
89
import { Loader2, Shuffle } from 'lucide-react';
910

1011
export function CoffeeDrawButton({
1112
hasExisting,
1213
defaultIncludeAlternants = true,
14+
defaultQuota = 10,
1315
}: {
1416
hasExisting: boolean;
1517
defaultIncludeAlternants?: boolean;
18+
defaultQuota?: number;
1619
}) {
1720
const router = useRouter();
1821
const [loading, setLoading] = useState(false);
1922
const [isPending, startTransition] = useTransition();
2023
const [includeAlternants, setIncludeAlternants] = useState(defaultIncludeAlternants);
24+
const [quota, setQuota] = useState(String(defaultQuota));
2125

2226
async function draw() {
2327
setLoading(true);
2428
try {
29+
const n = Math.max(1, Math.min(50, parseInt(quota, 10) || 10));
2530
const res = await fetch('/api/coffee-draws', {
2631
method: 'POST',
2732
headers: { 'Content-Type': 'application/json' },
28-
body: JSON.stringify({ includeAlternants }),
33+
body: JSON.stringify({ includeAlternants, quota: n }),
2934
});
3035
const data = await res.json();
3136
if (data?.success) {
@@ -47,7 +52,20 @@ export function CoffeeDrawButton({
4752
const busy = loading || isPending;
4853

4954
return (
50-
<div className="flex items-center gap-3">
55+
<div className="flex items-center gap-3 flex-wrap">
56+
<label className="flex items-center gap-1.5 text-xs text-muted-foreground select-none">
57+
Nombre
58+
<Input
59+
type="number"
60+
min={1}
61+
max={50}
62+
value={quota}
63+
onChange={(e) => setQuota(e.target.value)}
64+
disabled={busy}
65+
className="h-8 w-16"
66+
aria-label="Nombre d'apprenants à tirer"
67+
/>
68+
</label>
5169
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
5270
<Switch
5371
checked={includeAlternants}

components/dashboard/coffee-draw-widget.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export async function CoffeeDrawWidget() {
3131
<CoffeeDrawButton
3232
hasExisting={!!draw}
3333
defaultIncludeAlternants={draw?.includeAlternants ?? true}
34+
defaultQuota={draw?.quota || 10}
3435
/>
3536
</CardHeader>
3637
<CardContent>
@@ -43,8 +44,9 @@ export async function CoffeeDrawWidget() {
4344
<>
4445
<p className="text-xs text-muted-foreground mb-3">
4546
{draw.participants.length} apprenants tirés au sort · alternants{' '}
46-
{draw.includeAlternants ? 'inclus' : 'exclus'} · phase de test
47-
(aucun message envoyé) · re-tire un apprenant avec l’icône ↻
47+
{draw.includeAlternants ? 'inclus' : 'exclus'} · anti-répétition{' '}
48+
{draw.cooldownMonths} mois (priorité aux jamais-tirés) · phase de
49+
test · re-tire un apprenant avec l’icône ↻
4850
</p>
4951
<ul className="grid gap-2 grid-cols-1 sm:grid-cols-2">
5052
{draw.participants.map((p) => (
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- Café du mois : anti-répétition — nombre de mois de « cooldown » pendant
2+
-- lesquels un apprenant déjà tiré est évité (défaut 3).
3+
4+
ALTER TABLE "coffee_draws"
5+
ADD COLUMN IF NOT EXISTS "cooldown_months" integer DEFAULT 3 NOT NULL;

lib/db/schema/coffeeDraws.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ export const coffeeDraws = pgTable('coffee_draws', {
2121
// Alternants inclus dans le vivier de ce tirage (choisi au lancement). Le
2222
// re-tirage individuel réutilise ce même réglage.
2323
includeAlternants: boolean('include_alternants').notNull().default(true),
24+
// Anti-répétition : on évite les apprenants tirés lors des N derniers mois
25+
// (et on privilégie les jamais-tirés). Réutilisé par le re-tirage individuel.
26+
cooldownMonths: integer('cooldown_months').notNull().default(3),
2427
status: text('status').notNull().default('draft'),
2528
createdAt: timestamp('created_at').notNull().defaultNow(),
2629
});

lib/db/services/coffeeDraws.ts

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
79148
export 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

Comments
 (0)