Skip to content

Commit 517db85

Browse files
Merge pull request #179 from shepherdjerred/claude/weekly-elections-wednesday-Aqmn3
feat(birmel): make elections weekly on Wednesday
2 parents ab1c6d2 + 6ed4f81 commit 517db85

3 files changed

Lines changed: 60 additions & 37 deletions

File tree

packages/birmel/src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export const ElectionsConfigSchema = z.object({
110110
endTime: z.string().default("19:00"),
111111
timezone: z.string().default("America/Los_Angeles"),
112112
channelId: z.string().optional(),
113+
dayOfWeek: z.number().min(0).max(6).default(3), // 0=Sunday, 3=Wednesday, 6=Saturday
113114
});
114115

115116
export const ConfigSchema = z.object({

packages/birmel/src/elections/winner.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ export function determineWinner(
4040
.map(([persona, _]) => persona);
4141

4242
if (tiedCandidates.length > 1) {
43+
// Select a random winner from tied candidates
44+
const randomIndex = Math.floor(Math.random() * tiedCandidates.length);
45+
const randomWinner = tiedCandidates[randomIndex] ?? tiedCandidates[0] ?? "jerred";
4346
return {
44-
winner: null,
47+
winner: randomWinner,
4548
isTie: true,
4649
tiedCandidates,
4750
voteCounts,

packages/birmel/src/scheduler/jobs/elections.ts

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,29 @@ import { getConfig } from "../../config/index.js";
2525

2626
const logger = loggers.scheduler.child("elections");
2727

28-
function getCurrentTimeInTimezone(timezone: string): { hours: number; minutes: number } {
28+
function getCurrentTimeInTimezone(timezone: string): { hours: number; minutes: number; dayOfWeek: number } {
2929
const now = new Date();
30-
const formatter = new Intl.DateTimeFormat("en-US", {
30+
const timeFormatter = new Intl.DateTimeFormat("en-US", {
3131
timeZone: timezone,
3232
hour: "2-digit",
3333
minute: "2-digit",
3434
hour12: false,
3535
});
36+
const dayFormatter = new Intl.DateTimeFormat("en-US", {
37+
timeZone: timezone,
38+
weekday: "short",
39+
});
3640

37-
const parts = formatter.formatToParts(now);
38-
const hours = Number.parseInt(parts.find((p) => p.type === "hour")?.value ?? "0", 10);
39-
const minutes = Number.parseInt(parts.find((p) => p.type === "minute")?.value ?? "0", 10);
41+
const timeParts = timeFormatter.formatToParts(now);
42+
const hours = Number.parseInt(timeParts.find((p) => p.type === "hour")?.value ?? "0", 10);
43+
const minutes = Number.parseInt(timeParts.find((p) => p.type === "minute")?.value ?? "0", 10);
4044

41-
return { hours, minutes };
45+
// Get day of week (0=Sunday, 6=Saturday)
46+
const dayStr = dayFormatter.format(now);
47+
const dayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
48+
const dayOfWeek = dayMap[dayStr] ?? 0;
49+
50+
return { hours, minutes, dayOfWeek };
4251
}
4352

4453
function isElectionStartTime(): boolean {
@@ -48,26 +57,38 @@ function isElectionStartTime(): boolean {
4857
const [targetHour, targetMinute] = config.elections.startTime.split(":").map(Number);
4958
const current = getCurrentTimeInTimezone(config.elections.timezone);
5059

60+
// Check if it's the configured day of the week (default: Wednesday = 3)
61+
if (current.dayOfWeek !== config.elections.dayOfWeek) return false;
62+
5163
// Allow a 5-minute window for the scheduler
5264
const currentMinutes = current.hours * 60 + current.minutes;
5365
const targetMinutes = (targetHour ?? 0) * 60 + (targetMinute ?? 0);
5466

5567
return currentMinutes >= targetMinutes && currentMinutes < targetMinutes + 5;
5668
}
5769

58-
async function hasElectionToday(guildId: string): Promise<boolean> {
59-
const todayStart = new Date();
60-
todayStart.setHours(0, 0, 0, 0);
61-
const todayEnd = new Date();
62-
todayEnd.setHours(23, 59, 59, 999);
70+
async function hasElectionThisWeek(guildId: string): Promise<boolean> {
71+
const config = getConfig();
72+
const current = getCurrentTimeInTimezone(config.elections.timezone);
73+
const now = new Date();
74+
75+
// Calculate start of the current week (Sunday at 00:00:00) using configured timezone's day
76+
const weekStart = new Date(now);
77+
weekStart.setDate(now.getDate() - current.dayOfWeek);
78+
weekStart.setHours(0, 0, 0, 0);
79+
80+
// Calculate end of the current week (Saturday at 23:59:59)
81+
const weekEnd = new Date(weekStart);
82+
weekEnd.setDate(weekStart.getDate() + 6);
83+
weekEnd.setHours(23, 59, 59, 999);
6384

6485
const election = await prisma.electionPoll.findFirst({
6586
where: {
6687
guildId,
6788
pollType: "election",
6889
createdAt: {
69-
gte: todayStart,
70-
lte: todayEnd,
90+
gte: weekStart,
91+
lte: weekEnd,
7192
},
7293
},
7394
});
@@ -90,9 +111,9 @@ export async function checkAndStartElections(): Promise<void> {
90111

91112
for (const [guildId, oauthGuild] of guilds) {
92113
try {
93-
// Check if election already exists for today
94-
if (await hasElectionToday(guildId)) {
95-
logger.debug("Election already exists for today", { guildId });
114+
// Check if election already exists for this week
115+
if (await hasElectionThisWeek(guildId)) {
116+
logger.debug("Election already exists for this week", { guildId });
96117
continue;
97118
}
98119

@@ -158,7 +179,7 @@ export async function checkAndStartElections(): Promise<void> {
158179
});
159180
}
160181

161-
logger.info("Daily election created and started", {
182+
logger.info("Weekly election created and started", {
162183
guildId,
163184
channelId,
164185
candidates: candidates.length,
@@ -230,29 +251,27 @@ export async function processElectionResults(): Promise<void> {
230251

231252
const results = determineWinner(pollResults.data.answers);
232253

233-
// Handle tie - create runoff
254+
// Handle tie - announce and use random winner
234255
if (results.isTie) {
235-
await updateElectionStatus(election.id, "completed", {
236-
actualEnd: new Date(),
237-
voteCounts: JSON.stringify(results.voteCounts),
238-
});
239-
240-
// Create runoff poll
241-
const runoffEnd = new Date(Date.now() + 2 * 60 * 60 * 1000);
242-
await createElectionPoll({
243-
guildId: election.guildId,
244-
channelId: election.channelId,
245-
pollType: "runoff",
246-
scheduledStart: new Date(),
247-
scheduledEnd: runoffEnd,
248-
candidates: results.tiedCandidates,
249-
});
250-
251-
logger.info("Runoff created", {
256+
const client = getDiscordClient();
257+
const channel = await client.channels.fetch(election.channelId);
258+
if (channel?.isTextBased() && "send" in channel) {
259+
const tiedNames = results.tiedCandidates
260+
.map((name) => name.charAt(0).toUpperCase() + name.slice(1))
261+
.join(", ");
262+
const winnerName = (results.winner ?? "jerred").charAt(0).toUpperCase() + (results.winner ?? "jerred").slice(1);
263+
const voteCount = results.voteCounts[results.tiedCandidates[0] ?? ""] ?? 0;
264+
await channel.send(
265+
`🎲 **It's a tie!** ${tiedNames} each received ${String(voteCount)} votes. ` +
266+
`A random winner has been selected: **${winnerName}**! 🎉`
267+
);
268+
}
269+
270+
logger.info("Tie resolved with random winner", {
252271
guildId: election.guildId,
253272
tiedCandidates: results.tiedCandidates,
273+
randomWinner: results.winner,
254274
});
255-
continue;
256275
}
257276

258277
// Update guild owner

0 commit comments

Comments
 (0)