Skip to content

Commit cde21de

Browse files
cskartikeyclaude
andcommitted
last call: anyone who never shipped can ship through nov 5, and clippy DMs everyone with an hour or more, once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e21a687 commit cde21de

8 files changed

Lines changed: 105 additions & 7 deletions

File tree

‎app/models/program.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ module Program
77
EXTENSION_DAYS = 7
88
# Ship stays open this many days past the deadline, quietly: nobody's told, and the site keeps saying the deadline.
99
SHIP_GRACE_DAYS = 2
10+
# Last call, after it all closed: anyone who never shipped can still ship through this day (Project#ship_cutoff), and
11+
# ships:last_call DMs them to.
12+
LAST_CALL_THROUGH = Date.new(2026, 11, 5)
1013
# The first hours pay more (Shop::FIRST_RATE an hour; Shop::RATE after), and the stats count who's past them.
1114
HOURS_PER_REWARD = 10
1215
# Hackatime time from this day on counts: only projects worked on since then can be linked.

‎app/models/project.rb‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,9 +355,12 @@ def program_dates
355355
Program::DATES.begin..deadline
356356
end
357357

358-
# Ship closes at the end of the deadline where you are, plus Program::SHIP_GRACE_DAYS nobody's told about.
358+
# Ship closes at the end of the deadline where you are, plus Program::SHIP_GRACE_DAYS nobody's told about. Never
359+
# shipped (sent back counts), it stays open for last call (Program::LAST_CALL_THROUGH).
359360
def ship_cutoff
360-
(deadline + Program::SHIP_GRACE_DAYS).in_time_zone(user.time_zone).end_of_day
361+
last_day = deadline + Program::SHIP_GRACE_DAYS
362+
last_day = [ last_day, Program::LAST_CALL_THROUGH ].max unless shipped?
363+
last_day.in_time_zone(user.time_zone).end_of_day
361364
end
362365

363366
def may_ship?(now: Time.current)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class AddLastCallDmedAtToProjects < ActiveRecord::Migration[8.1]
2+
def change
3+
# When Clippy DMed them that shipping's open again (ships:last_call), so nobody hears it twice.
4+
add_column :projects, :last_call_dmed_at, :datetime
5+
end
6+
end

‎db/schema.rb‎

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/tasks/ships.rake‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,38 @@ namespace :ships do
2828
puts
2929
puts "#{sent} of #{owed.size} #{dry_run ? "would be " : ""}DMed."
3030
end
31+
32+
# Last call, after shipping closed: Clippy DMs everyone who never shipped (nothing approved or in review) but logged
33+
# an hour or more that shipping's open for them again, through Program::LAST_CALL_THROUGH. Noted on the project
34+
# (last_call_dmed_at), so running it twice sends nothing new. DRY_RUN=1 lists who'd hear without sending.
35+
#
36+
# bin/rails ships:last_call DRY_RUN=1
37+
# bin/rails ships:last_call
38+
desc "DM everyone who never shipped that shipping's open again for last call"
39+
task last_call: :environment do
40+
dm = "Shipping's open again for anyone who didn't make it. *%{title}* can ship until the end of %{until}, " \
41+
"finished or not. If it plays, ship it.\n:clippy-hopeful: clippy propped the door back open for you. :blob-wiggle:"
42+
dry_run = ENV["DRY_RUN"].present?
43+
owed = Project.where(last_call_dmed_at: nil)
44+
.where.not(id: Ship.where(status: %w[approved in_review]).select(:project_id))
45+
.includes(:user).order(:id)
46+
.select { |project| project.hours_logged >= 1 }
47+
48+
sent = owed.count do |project|
49+
user = project.user
50+
reachable = user.slack_id.present? && user.slack_muted_at.nil?
51+
puts format("%-20s %-30s %5.1f hrs %s", user.public_name[0, 20], project.title[0, 30], project.hours_logged,
52+
reachable ? (dry_run ? "would DM" : "DMed") : "skipped (not on Slack, or stopped Clippy)")
53+
if reachable && !dry_run
54+
user.slack_dmed!
55+
project.update_column(:last_call_dmed_at, Time.current)
56+
text = format(dm, title: project.title, until: Program::LAST_CALL_THROUGH.strftime("%b %-d"))
57+
SlackMessageJob.perform_later(user.slack_id, text, link: [ "Ship it", "#{Rails.configuration.x.app_url}/ship" ])
58+
end
59+
reachable
60+
end
61+
62+
puts
63+
puts "#{sent} of #{owed.size} #{dry_run ? "would be " : ""}DMed."
64+
end
3165
end

‎test/controllers/ships_controller_test.rb‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class ShipsControllerTest < ActionDispatch::IntegrationTest
6666
assert_select ".ship__error", /has to show the game running/
6767
end
6868

69-
test "shipping closes after the deadline where you are, with two quiet days nobody's told about" do
69+
test "never shipped, shipping stays open past the quiet days through last call, then closes" do
7070
link_hackatime(users(:orpheus))
7171
projects(:orpheus).update!(hackatime_projects: [ "rhythm-game" ], repo_later: true, buddy_skipped: true)
7272
users(:orpheus).update!(timezone: "America/New_York")
@@ -79,7 +79,12 @@ class ShipsControllerTest < ActionDispatch::IntegrationTest
7979
assert_select ".project__ship", /Ship/
8080
end
8181

82-
travel_to Time.utc(2026, 10, 30, 5) do
82+
travel_to Time.utc(2026, 11, 6, 4) do # 11pm on Nov 5 in New York: last call, still open
83+
get project_ship_path
84+
assert_select "form.ship__form"
85+
end
86+
87+
travel_to Time.utc(2026, 11, 6, 6) do
8388
get project_ship_path
8489
assert_response :success
8590
assert_select ".ship__done[data-state=closed] .ship__done-title", "Shipping's closed."

‎test/models/project_test.rb‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,24 @@ class ProjectDeadlineTest < ActiveSupport::TestCase
187187
assert_equal Date.new(2026, 10, 6)..Date.new(2026, 10, 27), project.program_dates
188188
end
189189

190-
test "ship closes at the end of the deadline where you are, plus two quiet days" do
190+
test "ship closes at the end of the deadline where you are, plus two quiet days, once you've shipped" do
191191
project = projects(:orpheus)
192+
def project.shipped? = true
192193
assert_equal Time.utc(2026, 10, 29, 23, 59, 59), project.ship_cutoff.change(usec: 0), "UTC until they've said where they are"
193194

194195
users(:orpheus).update!(timezone: "America/New_York")
195-
assert project.reload.may_ship?(now: Time.utc(2026, 10, 30, 2))
196+
project.user.reload
197+
assert project.may_ship?(now: Time.utc(2026, 10, 30, 2))
196198
assert_not project.may_ship?(now: Time.utc(2026, 10, 30, 4))
197199
end
198200

201+
test "never shipped, ship stays open through last call" do
202+
project = projects(:orpheus)
203+
assert_equal Time.utc(2026, 11, 5, 23, 59, 59), project.ship_cutoff.change(usec: 0)
204+
assert project.may_ship?(now: Time.utc(2026, 11, 2, 12))
205+
assert_not project.may_ship?(now: Time.utc(2026, 11, 6, 1))
206+
end
207+
199208
test "nobody can ask for more time any more" do
200209
project = projects(:orpheus)
201210
link_hackatime(users(:orpheus))

‎test/models/ship_test.rb‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,43 @@ class ShipTest < ActiveSupport::TestCase
102102
assert_equal [ "Ship it", "https://wrong.hackclub.com/ship" ], dms.first[:args].third["link"]
103103
end
104104

105+
test "last call DMs everyone who never shipped with an hour or more, once, and a dry run sends nothing" do
106+
require "rake"
107+
Rails.application.load_tasks unless Rake::Task.task_defined?("ships:last_call")
108+
ship_now(hours: 2) # in review: not owed
109+
ana = projects(:ana)
110+
link_hackatime(ana.user, token: "token-heidi")
111+
ana.update!(hackatime_projects: [ "heidis-game" ])
112+
ana.ships.create!(title: "Pong in Figma", description: "Two paddles, one ball, and every frame is a Figma component.",
113+
repo_url: "https://example.com/repo", demo_url: "https://example.com/demo", hours: 1,
114+
screenshot: fixture_upload, screenshot_shows_game: true).update!(status: "rejected")
115+
ana.user.streak_activities.create!(activity_date: Program::DATES.begin, coded_seconds: 2 * 3600)
116+
117+
with_env("DRY_RUN" => "1") { capture_io { Rake::Task["ships:last_call"].execute } }
118+
assert_empty enqueued_jobs.select { |job| job[:job] == SlackMessageJob }
119+
120+
capture_io { Rake::Task["ships:last_call"].execute }
121+
dms = enqueued_jobs.select { |job| job[:job] == SlackMessageJob }
122+
assert_equal [ "U0ANA" ], dms.map { |job| job[:args].first }
123+
assert_equal "Shipping's open again for anyone who didn't make it. *Pong in Figma* can ship until the end of Nov 5, " \
124+
"finished or not. If it plays, ship it.\n:clippy-hopeful: clippy propped the door back open for you. :blob-wiggle:",
125+
dms.first[:args].second
126+
assert_not_nil ana.reload.last_call_dmed_at
127+
128+
clear_enqueued_jobs
129+
capture_io { Rake::Task["ships:last_call"].execute }
130+
assert_empty enqueued_jobs.select { |job| job[:job] == SlackMessageJob }, "once"
131+
end
132+
133+
test "last call skips anyone under an hour" do
134+
require "rake"
135+
Rails.application.load_tasks unless Rake::Task.task_defined?("ships:last_call")
136+
@project.user.streak_activities.create!(activity_date: Program::DATES.begin, coded_seconds: 30 * 60)
137+
138+
capture_io { Rake::Task["ships:last_call"].execute }
139+
assert_empty enqueued_jobs.select { |job| job[:job] == SlackMessageJob }
140+
end
141+
105142
private
106143
def with_env(vars)
107144
originals = vars.keys.index_with { |key| ENV[key] }

0 commit comments

Comments
 (0)