From 1ed1a544f61689d5cd17ea26f6b221562701ad70 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:31:14 +0200 Subject: [PATCH 01/26] feat: add check_in_code and source columns for self check-in --- db/migrate/20260703083036_add_check_in_code_to_events.rb | 6 ++++++ db/migrate/20260703083039_add_check_in_code_to_workshops.rb | 6 ++++++ db/migrate/20260703083048_add_source_to_invitations.rb | 5 +++++ .../20260703083051_add_source_to_workshop_invitations.rb | 5 +++++ 4 files changed, 22 insertions(+) create mode 100644 db/migrate/20260703083036_add_check_in_code_to_events.rb create mode 100644 db/migrate/20260703083039_add_check_in_code_to_workshops.rb create mode 100644 db/migrate/20260703083048_add_source_to_invitations.rb create mode 100644 db/migrate/20260703083051_add_source_to_workshop_invitations.rb diff --git a/db/migrate/20260703083036_add_check_in_code_to_events.rb b/db/migrate/20260703083036_add_check_in_code_to_events.rb new file mode 100644 index 000000000..961d7242f --- /dev/null +++ b/db/migrate/20260703083036_add_check_in_code_to_events.rb @@ -0,0 +1,6 @@ +class AddCheckInCodeToEvents < ActiveRecord::Migration[8.1] + def change + add_column :events, :check_in_code, :string + add_index :events, :check_in_code, unique: true + end +end diff --git a/db/migrate/20260703083039_add_check_in_code_to_workshops.rb b/db/migrate/20260703083039_add_check_in_code_to_workshops.rb new file mode 100644 index 000000000..dfa0e807b --- /dev/null +++ b/db/migrate/20260703083039_add_check_in_code_to_workshops.rb @@ -0,0 +1,6 @@ +class AddCheckInCodeToWorkshops < ActiveRecord::Migration[8.1] + def change + add_column :workshops, :check_in_code, :string + add_index :workshops, :check_in_code, unique: true + end +end diff --git a/db/migrate/20260703083048_add_source_to_invitations.rb b/db/migrate/20260703083048_add_source_to_invitations.rb new file mode 100644 index 000000000..776308173 --- /dev/null +++ b/db/migrate/20260703083048_add_source_to_invitations.rb @@ -0,0 +1,5 @@ +class AddSourceToInvitations < ActiveRecord::Migration[8.1] + def change + add_column :invitations, :source, :string + end +end diff --git a/db/migrate/20260703083051_add_source_to_workshop_invitations.rb b/db/migrate/20260703083051_add_source_to_workshop_invitations.rb new file mode 100644 index 000000000..9b280fa83 --- /dev/null +++ b/db/migrate/20260703083051_add_source_to_workshop_invitations.rb @@ -0,0 +1,5 @@ +class AddSourceToWorkshopInvitations < ActiveRecord::Migration[8.1] + def change + add_column :workshop_invitations, :source, :string + end +end From 40794bc4285681b15fbc939a3089008b18f7cbc3 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:31:56 +0200 Subject: [PATCH 02/26] feat: add check_in_code generation from EFF word list --- app/models/concerns/check_in_code.rb | 44 + app/models/event.rb | 1 + app/models/workshop.rb | 1 + lib/words/check_in_words.txt | 7776 ++++++++++++++++++++++++++ spec/models/event_spec.rb | 13 + spec/models/workshop_spec.rb | 13 + 6 files changed, 7848 insertions(+) create mode 100644 app/models/concerns/check_in_code.rb create mode 100644 lib/words/check_in_words.txt diff --git a/app/models/concerns/check_in_code.rb b/app/models/concerns/check_in_code.rb new file mode 100644 index 000000000..04b8f9841 --- /dev/null +++ b/app/models/concerns/check_in_code.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module CheckInCode + extend ActiveSupport::Concern + + WORD_LIST_PATH = Rails.root.join("lib", "words", "check_in_words.txt") + + class_methods do + def word_list + @word_list ||= File.readlines(WORD_LIST_PATH).map(&:strip).freeze + end + end + + included do + before_create :set_check_in_code + end + + def generate_check_in_code! + loop do + code = self.class.word_list.sample(3).join("-") + unless self.class.exists?(check_in_code: code) + update_column(:check_in_code, code) + return code + end + end + end + + def check_in_url + prefix = model_name.singular == "event" ? "e" : "w" + route_name = :"check_in_#{prefix}_url" + Rails.application.routes.url_helpers.public_send( + route_name, check_in_code + ) + end + + private + + def set_check_in_code + loop do + self.check_in_code = self.class.word_list.sample(3).join("-") + break unless self.class.exists?(check_in_code: check_in_code) + end + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 2dd5a784a..a8375b581 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -2,6 +2,7 @@ class Event < ApplicationRecord include DateTimeConcerns include Listable include Invitable + include CheckInCode include EventHelper attr_accessor :local_date, :local_time, :local_end_time diff --git a/app/models/workshop.rb b/app/models/workshop.rb index 28f86ce9b..5c65552c4 100644 --- a/app/models/workshop.rb +++ b/app/models/workshop.rb @@ -1,6 +1,7 @@ class Workshop < ApplicationRecord include DateTimeConcerns include Invitable + include CheckInCode include Listable attr_accessor :local_date, :local_time, :local_end_time, :rsvp_open_local_date, :rsvp_open_local_time diff --git a/lib/words/check_in_words.txt b/lib/words/check_in_words.txt new file mode 100644 index 000000000..caf71f526 --- /dev/null +++ b/lib/words/check_in_words.txt @@ -0,0 +1,7776 @@ +abacus +abdomen +abdominal +abide +abiding +ability +ablaze +able +abnormal +abrasion +abrasive +abreast +abridge +abroad +abruptly +absence +absentee +absently +absinthe +absolute +absolve +abstain +abstract +absurd +accent +acclaim +acclimate +accompany +account +accuracy +accurate +accustom +acetone +achiness +aching +acid +acorn +acquaint +acquire +acre +acrobat +acronym +acting +action +activate +activator +active +activism +activist +activity +actress +acts +acutely +acuteness +aeration +aerobics +aerosol +aerospace +afar +affair +affected +affecting +affection +affidavit +affiliate +affirm +affix +afflicted +affluent +afford +affront +aflame +afloat +aflutter +afoot +afraid +afterglow +afterlife +aftermath +aftermost +afternoon +aged +ageless +agency +agenda +agent +aggregate +aghast +agile +agility +aging +agnostic +agonize +agonizing +agony +agreeable +agreeably +agreed +agreeing +agreement +aground +ahead +ahoy +aide +aids +aim +ajar +alabaster +alarm +albatross +album +alfalfa +algebra +algorithm +alias +alibi +alienable +alienate +aliens +alike +alive +alkaline +alkalize +almanac +almighty +almost +aloe +aloft +aloha +alone +alongside +aloof +alphabet +alright +although +altitude +alto +aluminum +alumni +always +amaretto +amaze +amazingly +amber +ambiance +ambiguity +ambiguous +ambition +ambitious +ambulance +ambush +amendable +amendment +amends +amenity +amiable +amicably +amid +amigo +amino +amiss +ammonia +ammonium +amnesty +amniotic +among +amount +amperage +ample +amplifier +amplify +amply +amuck +amulet +amusable +amused +amusement +amuser +amusing +anaconda +anaerobic +anagram +anatomist +anatomy +anchor +anchovy +ancient +android +anemia +anemic +aneurism +anew +angelfish +angelic +anger +angled +angler +angles +angling +angrily +angriness +anguished +angular +animal +animate +animating +animation +animator +anime +animosity +ankle +annex +annotate +announcer +annoying +annually +annuity +anointer +another +answering +antacid +antarctic +anteater +antelope +antennae +anthem +anthill +anthology +antibody +antics +antidote +antihero +antiquely +antiques +antiquity +antirust +antitoxic +antitrust +antiviral +antivirus +antler +antonym +antsy +anvil +anybody +anyhow +anymore +anyone +anyplace +anything +anytime +anyway +anywhere +aorta +apache +apostle +appealing +appear +appease +appeasing +appendage +appendix +appetite +appetizer +applaud +applause +apple +appliance +applicant +applied +apply +appointee +appraisal +appraiser +apprehend +approach +approval +approve +apricot +april +apron +aptitude +aptly +aqua +aqueduct +arbitrary +arbitrate +ardently +area +arena +arguable +arguably +argue +arise +armadillo +armband +armchair +armed +armful +armhole +arming +armless +armoire +armored +armory +armrest +army +aroma +arose +around +arousal +arrange +array +arrest +arrival +arrive +arrogance +arrogant +arson +art +ascend +ascension +ascent +ascertain +ashamed +ashen +ashes +ashy +aside +askew +asleep +asparagus +aspect +aspirate +aspire +aspirin +astonish +astound +astride +astrology +astronaut +astronomy +astute +atlantic +atlas +atom +atonable +atop +atrium +atrocious +atrophy +attach +attain +attempt +attendant +attendee +attention +attentive +attest +attic +attire +attitude +attractor +attribute +atypical +auction +audacious +audacity +audible +audibly +audience +audio +audition +augmented +august +authentic +author +autism +autistic +autograph +automaker +automated +automatic +autopilot +available +avalanche +avatar +avenge +avenging +avenue +average +aversion +avert +aviation +aviator +avid +avoid +await +awaken +award +aware +awhile +awkward +awning +awoke +awry +axis +babble +babbling +babied +baboon +backache +backboard +backboned +backdrop +backed +backer +backfield +backfire +backhand +backing +backlands +backlash +backless +backlight +backlit +backlog +backpack +backpedal +backrest +backroom +backshift +backside +backslid +backspace +backspin +backstab +backstage +backtalk +backtrack +backup +backward +backwash +backwater +backyard +bacon +bacteria +bacterium +badass +badge +badland +badly +badness +baffle +baffling +bagel +bagful +baggage +bagged +baggie +bagginess +bagging +baggy +bagpipe +baguette +baked +bakery +bakeshop +baking +balance +balancing +balcony +balmy +balsamic +bamboo +banana +banish +banister +banjo +bankable +bankbook +banked +banker +banking +banknote +bankroll +banner +bannister +banshee +banter +barbecue +barbed +barbell +barber +barcode +barge +bargraph +barista +baritone +barley +barmaid +barman +barn +barometer +barrack +barracuda +barrel +barrette +barricade +barrier +barstool +bartender +barterer +bash +basically +basics +basil +basin +basis +basket +batboy +batch +bath +baton +bats +battalion +battered +battering +battery +batting +battle +bauble +bazooka +blabber +bladder +blade +blah +blame +blaming +blanching +blandness +blank +blaspheme +blasphemy +blast +blatancy +blatantly +blazer +blazing +bleach +bleak +bleep +blemish +blend +bless +blighted +blimp +bling +blinked +blinker +blinking +blinks +blip +blissful +blitz +blizzard +bloated +bloating +blob +blog +bloomers +blooming +blooper +blot +blouse +blubber +bluff +bluish +blunderer +blunt +blurb +blurred +blurry +blurt +blush +blustery +boaster +boastful +boasting +boat +bobbed +bobbing +bobble +bobcat +bobsled +bobtail +bodacious +body +bogged +boggle +bogus +boil +bok +bolster +bolt +bonanza +bonded +bonding +bondless +boned +bonehead +boneless +bonelike +boney +bonfire +bonnet +bonsai +bonus +bony +boogeyman +boogieman +book +boondocks +booted +booth +bootie +booting +bootlace +bootleg +boots +boozy +borax +boring +borough +borrower +borrowing +boss +botanical +botanist +botany +botch +both +bottle +bottling +bottom +bounce +bouncing +bouncy +bounding +boundless +bountiful +bovine +boxcar +boxer +boxing +boxlike +boxy +breach +breath +breeches +breeching +breeder +breeding +breeze +breezy +brethren +brewery +brewing +briar +bribe +brick +bride +bridged +brigade +bright +brilliant +brim +bring +brink +brisket +briskly +briskness +bristle +brittle +broadband +broadcast +broaden +broadly +broadness +broadside +broadways +broiler +broiling +broken +broker +bronchial +bronco +bronze +bronzing +brook +broom +brought +browbeat +brownnose +browse +browsing +bruising +brunch +brunette +brunt +brush +brussels +brute +brutishly +bubble +bubbling +bubbly +buccaneer +bucked +bucket +buckle +buckshot +buckskin +bucktooth +buckwheat +buddhism +buddhist +budding +buddy +budget +buffalo +buffed +buffer +buffing +buffoon +buggy +bulb +bulge +bulginess +bulgur +bulk +bulldog +bulldozer +bullfight +bullfrog +bullhorn +bullion +bullish +bullpen +bullring +bullseye +bullwhip +bully +bunch +bundle +bungee +bunion +bunkbed +bunkhouse +bunkmate +bunny +bunt +busboy +bush +busily +busload +bust +busybody +buzz +cabana +cabbage +cabbie +cabdriver +cable +caboose +cache +cackle +cacti +cactus +caddie +caddy +cadet +cadillac +cadmium +cage +cahoots +cake +calamari +calamity +calcium +calculate +calculus +caliber +calibrate +calm +caloric +calorie +calzone +camcorder +cameo +camera +camisole +camper +campfire +camping +campsite +campus +canal +canary +cancel +candied +candle +candy +cane +canine +canister +cannabis +canned +canning +cannon +cannot +canola +canon +canopener +canopy +canteen +canyon +capable +capably +capacity +cape +capillary +capital +capitol +capped +capricorn +capsize +capsule +caption +captivate +captive +captivity +capture +caramel +carat +caravan +carbon +cardboard +carded +cardiac +cardigan +cardinal +cardstock +carefully +caregiver +careless +caress +caretaker +cargo +caring +carless +carload +carmaker +carnage +carnation +carnival +carnivore +carol +carpenter +carpentry +carpool +carport +carried +carrot +carrousel +carry +cartel +cartload +carton +cartoon +cartridge +cartwheel +carve +carving +carwash +cascade +case +cash +casing +casino +casket +cassette +casually +casualty +catacomb +catalog +catalyst +catalyze +catapult +cataract +catatonic +catcall +catchable +catcher +catching +catchy +caterer +catering +catfight +catfish +cathedral +cathouse +catlike +catnap +catnip +catsup +cattail +cattishly +cattle +catty +catwalk +caucasian +caucus +causal +causation +cause +causing +cauterize +caution +cautious +cavalier +cavalry +caviar +cavity +cedar +celery +celestial +celibacy +celibate +celtic +cement +census +ceramics +ceremony +certainly +certainty +certified +certify +cesarean +cesspool +chafe +chaffing +chain +chair +chalice +challenge +chamber +chamomile +champion +chance +change +channel +chant +chaos +chaperone +chaplain +chapped +chaps +chapter +character +charbroil +charcoal +charger +charging +chariot +charity +charm +charred +charter +charting +chase +chasing +chaste +chastise +chastity +chatroom +chatter +chatting +chatty +cheating +cheddar +cheek +cheer +cheese +cheesy +chef +chemicals +chemist +chemo +cherisher +cherub +chess +chest +chevron +chevy +chewable +chewer +chewing +chewy +chief +chihuahua +childcare +childhood +childish +childless +childlike +chili +chill +chimp +chip +chirping +chirpy +chitchat +chivalry +chive +chloride +chlorine +choice +chokehold +choking +chomp +chooser +choosing +choosy +chop +chosen +chowder +chowtime +chrome +chubby +chuck +chug +chummy +chump +chunk +churn +chute +cider +cilantro +cinch +cinema +cinnamon +circle +circling +circular +circulate +circus +citable +citadel +citation +citizen +citric +citrus +city +civic +civil +clad +claim +clambake +clammy +clamor +clamp +clamshell +clang +clanking +clapped +clapper +clapping +clarify +clarinet +clarity +clash +clasp +class +clatter +clause +clavicle +claw +clay +clean +clear +cleat +cleaver +cleft +clench +clergyman +clerical +clerk +clever +clicker +client +climate +climatic +cling +clinic +clinking +clip +clique +cloak +clobber +clock +clone +cloning +closable +closure +clothes +clothing +cloud +clover +clubbed +clubbing +clubhouse +clump +clumsily +clumsy +clunky +clustered +clutch +clutter +coach +coagulant +coastal +coaster +coasting +coastland +coastline +coat +coauthor +cobalt +cobbler +cobweb +cocoa +coconut +cod +coeditor +coerce +coexist +coffee +cofounder +cognition +cognitive +cogwheel +coherence +coherent +cohesive +coil +coke +cola +cold +coleslaw +coliseum +collage +collapse +collar +collected +collector +collide +collie +collision +colonial +colonist +colonize +colony +colossal +colt +coma +come +comfort +comfy +comic +coming +comma +commence +commend +comment +commerce +commode +commodity +commodore +common +commotion +commute +commuting +compacted +compacter +compactly +compactor +companion +company +compare +compel +compile +comply +component +composed +composer +composite +compost +composure +compound +compress +comprised +computer +computing +comrade +concave +conceal +conceded +concept +concerned +concert +conch +concierge +concise +conclude +concrete +concur +condense +condiment +condition +condone +conducive +conductor +conduit +cone +confess +confetti +confidant +confident +confider +confiding +configure +confined +confining +confirm +conflict +conform +confound +confront +confused +confusing +confusion +congenial +congested +congrats +congress +conical +conjoined +conjure +conjuror +connected +connector +consensus +consent +console +consoling +consonant +constable +constant +constrain +constrict +construct +consult +consumer +consuming +contact +container +contempt +contend +contented +contently +contents +contest +context +contort +contour +contrite +control +contusion +convene +convent +copartner +cope +copied +copier +copilot +coping +copious +copper +copy +coral +cork +cornball +cornbread +corncob +cornea +corned +corner +cornfield +cornflake +cornhusk +cornmeal +cornstalk +corny +coronary +coroner +corporal +corporate +corral +correct +corridor +corrode +corroding +corrosive +corsage +corset +cortex +cosigner +cosmetics +cosmic +cosmos +cosponsor +cost +cottage +cotton +couch +cough +could +countable +countdown +counting +countless +country +county +courier +covenant +cover +coveted +coveting +coyness +cozily +coziness +cozy +crabbing +crabgrass +crablike +crabmeat +cradle +cradling +crafter +craftily +craftsman +craftwork +crafty +cramp +cranberry +crane +cranial +cranium +crank +crate +crave +craving +crawfish +crawlers +crawling +crayfish +crayon +crazed +crazily +craziness +crazy +creamed +creamer +creamlike +crease +creasing +creatable +create +creation +creative +creature +credible +credibly +credit +creed +creme +creole +crepe +crept +crescent +crested +cresting +crestless +crevice +crewless +crewman +crewmate +crib +cricket +cried +crier +crimp +crimson +cringe +cringing +crinkle +crinkly +crisped +crisping +crisply +crispness +crispy +criteria +critter +croak +crock +crook +croon +crop +cross +crouch +crouton +crowbar +crowd +crown +crucial +crudely +crudeness +cruelly +cruelness +cruelty +crumb +crummiest +crummy +crumpet +crumpled +cruncher +crunching +crunchy +crusader +crushable +crushed +crusher +crushing +crust +crux +crying +cryptic +crystal +cubbyhole +cube +cubical +cubicle +cucumber +cuddle +cuddly +cufflink +culinary +culminate +culpable +culprit +cultivate +cultural +culture +cupbearer +cupcake +cupid +cupped +cupping +curable +curator +curdle +cure +curfew +curing +curled +curler +curliness +curling +curly +curry +curse +cursive +cursor +curtain +curtly +curtsy +curvature +curve +curvy +cushy +cusp +cussed +custard +custodian +custody +customary +customer +customize +customs +cut +cycle +cyclic +cycling +cyclist +cylinder +cymbal +cytoplasm +cytoplast +dab +dad +daffodil +dagger +daily +daintily +dainty +dairy +daisy +dallying +dance +dancing +dandelion +dander +dandruff +dandy +danger +dangle +dangling +daredevil +dares +daringly +darkened +darkening +darkish +darkness +darkroom +darling +darn +dart +darwinism +dash +dastardly +data +datebook +dating +daughter +daunting +dawdler +dawn +daybed +daybreak +daycare +daydream +daylight +daylong +dayroom +daytime +dazzler +dazzling +deacon +deafening +deafness +dealer +dealing +dealmaker +dealt +dean +debatable +debate +debating +debit +debrief +debtless +debtor +debug +debunk +decade +decaf +decal +decathlon +decay +deceased +deceit +deceiver +deceiving +december +decency +decent +deception +deceptive +decibel +decidable +decimal +decimeter +decipher +deck +declared +decline +decode +decompose +decorated +decorator +decoy +decrease +decree +dedicate +dedicator +deduce +deduct +deed +deem +deepen +deeply +deepness +deface +defacing +defame +default +defeat +defection +defective +defendant +defender +defense +defensive +deferral +deferred +defiance +defiant +defile +defiling +define +definite +deflate +deflation +deflator +deflected +deflector +defog +deforest +defraud +defrost +deftly +defuse +defy +degraded +degrading +degrease +degree +dehydrate +deity +dejected +delay +delegate +delegator +delete +deletion +delicacy +delicate +delicious +delighted +delirious +delirium +deliverer +delivery +delouse +delta +deluge +delusion +deluxe +demanding +demeaning +demeanor +demise +democracy +democrat +demote +demotion +demystify +denatured +deniable +denial +denim +denote +dense +density +dental +dentist +denture +deny +deodorant +deodorize +departed +departure +depict +deplete +depletion +deplored +deploy +deport +depose +depraved +depravity +deprecate +depress +deprive +depth +deputize +deputy +derail +deranged +derby +derived +desecrate +deserve +deserving +designate +designed +designer +designing +deskbound +desktop +deskwork +desolate +despair +despise +despite +destiny +destitute +destruct +detached +detail +detection +detective +detector +detention +detergent +detest +detonate +detonator +detoxify +detract +deuce +devalue +deviancy +deviant +deviate +deviation +deviator +device +devious +devotedly +devotee +devotion +devourer +devouring +devoutly +dexterity +dexterous +diabetes +diabetic +diabolic +diagnoses +diagnosis +diagram +dial +diameter +diaper +diaphragm +diary +dice +dicing +dictate +dictation +dictator +difficult +diffused +diffuser +diffusion +diffusive +dig +dilation +diligence +diligent +dill +dilute +dime +diminish +dimly +dimmed +dimmer +dimness +dimple +diner +dingbat +dinghy +dinginess +dingo +dingy +dining +dinner +diocese +dioxide +diploma +dipped +dipper +dipping +directed +direction +directive +directly +directory +direness +dirtiness +disabled +disagree +disallow +disarm +disarray +disaster +disband +disbelief +disburse +discard +discern +discharge +disclose +discolor +discount +discourse +discover +discuss +disdain +disengage +disfigure +disgrace +dish +disinfect +disjoin +disk +dislike +disliking +dislocate +dislodge +disloyal +dismantle +dismay +dismiss +dismount +disobey +disorder +disown +disparate +disparity +dispatch +dispense +dispersal +dispersed +disperser +displace +display +displease +disposal +dispose +disprove +dispute +disregard +disrupt +dissuade +distance +distant +distaste +distill +distinct +distort +distract +distress +district +distrust +ditch +ditto +ditzy +dividable +divided +dividend +dividers +dividing +divinely +diving +divinity +divisible +divisibly +division +divisive +divorcee +dizziness +dizzy +doable +docile +dock +doctrine +document +dodge +dodgy +doily +doing +dole +dollar +dollhouse +dollop +dolly +dolphin +domain +domelike +domestic +dominion +dominoes +donated +donation +donator +donor +donut +doodle +doorbell +doorframe +doorknob +doorman +doormat +doornail +doorpost +doorstep +doorstop +doorway +doozy +dork +dormitory +dorsal +dosage +dose +dotted +doubling +douche +dove +down +dowry +doze +drab +dragging +dragonfly +dragonish +dragster +drainable +drainage +drained +drainer +drainpipe +dramatic +dramatize +drank +drapery +drastic +draw +dreaded +dreadful +dreadlock +dreamboat +dreamily +dreamland +dreamless +dreamlike +dreamt +dreamy +drearily +dreary +drench +dress +drew +dribble +dried +drier +drift +driller +drilling +drinkable +drinking +dripping +drippy +drivable +driven +driver +driveway +driving +drizzle +drizzly +drone +drool +droop +drop-down +dropbox +dropkick +droplet +dropout +dropper +drove +drown +drowsily +drudge +drum +dry +dubbed +dubiously +duchess +duckbill +ducking +duckling +ducktail +ducky +duct +dude +duffel +dugout +duh +duke +duller +dullness +duly +dumping +dumpling +dumpster +duo +dupe +duplex +duplicate +duplicity +durable +durably +duration +duress +during +dusk +dust +dutiful +duty +duvet +dwarf +dweeb +dwelled +dweller +dwelling +dwindle +dwindling +dynamic +dynamite +dynasty +dyslexia +dyslexic +each +eagle +earache +eardrum +earflap +earful +earlobe +early +earmark +earmuff +earphone +earpiece +earplugs +earring +earshot +earthen +earthlike +earthling +earthly +earthworm +earthy +earwig +easeful +easel +easiest +easily +easiness +easing +eastbound +eastcoast +easter +eastward +eatable +eaten +eatery +eating +eats +ebay +ebony +ebook +ecard +eccentric +echo +eclair +eclipse +ecologist +ecology +economic +economist +economy +ecosphere +ecosystem +edge +edginess +edging +edgy +edition +editor +educated +education +educator +eel +effective +effects +efficient +effort +eggbeater +egging +eggnog +eggplant +eggshell +egomaniac +egotism +egotistic +either +eject +elaborate +elastic +elated +elbow +eldercare +elderly +eldest +electable +election +elective +elephant +elevate +elevating +elevation +elevator +eleven +elf +eligible +eligibly +eliminate +elite +elitism +elixir +elk +ellipse +elliptic +elm +elongated +elope +eloquence +eloquent +elsewhere +elude +elusive +elves +email +embargo +embark +embassy +embattled +embellish +ember +embezzle +emblaze +emblem +embody +embolism +emboss +embroider +emcee +emerald +emergency +emission +emit +emote +emoticon +emotion +empathic +empathy +emperor +emphases +emphasis +emphasize +emphatic +empirical +employed +employee +employer +emporium +empower +emptier +emptiness +empty +emu +enable +enactment +enamel +enchanted +enchilada +encircle +enclose +enclosure +encode +encore +encounter +encourage +encroach +encrust +encrypt +endanger +endeared +endearing +ended +ending +endless +endnote +endocrine +endorphin +endorse +endowment +endpoint +endurable +endurance +enduring +energetic +energize +energy +enforced +enforcer +engaged +engaging +engine +engorge +engraved +engraver +engraving +engross +engulf +enhance +enigmatic +enjoyable +enjoyably +enjoyer +enjoying +enjoyment +enlarged +enlarging +enlighten +enlisted +enquirer +enrage +enrich +enroll +enslave +ensnare +ensure +entail +entangled +entering +entertain +enticing +entire +entitle +entity +entomb +entourage +entrap +entree +entrench +entrust +entryway +entwine +enunciate +envelope +enviable +enviably +envious +envision +envoy +envy +enzyme +epic +epidemic +epidermal +epidermis +epidural +epilepsy +epileptic +epilogue +epiphany +episode +equal +equate +equation +equator +equinox +equipment +equity +equivocal +eradicate +erasable +erased +eraser +erasure +ergonomic +errand +errant +erratic +error +erupt +escalate +escalator +escapable +escapade +escapist +escargot +eskimo +esophagus +espionage +espresso +esquire +essay +essence +essential +establish +estate +esteemed +estimate +estimator +estranged +estrogen +etching +eternal +eternity +ethanol +ether +ethically +ethics +euphemism +evacuate +evacuee +evade +evaluate +evaluator +evaporate +evasion +evasive +even +everglade +evergreen +everybody +everyday +everyone +evict +evidence +evident +evil +evoke +evolution +evolve +exact +exalted +example +excavate +excavator +exceeding +exception +excess +exchange +excitable +exciting +exclaim +exclude +excluding +exclusion +exclusive +excretion +excretory +excursion +excusable +excusably +excuse +exemplary +exemplify +exemption +exerciser +exert +exes +exfoliate +exhale +exhaust +exhume +exile +existing +exit +exodus +exonerate +exorcism +exorcist +expand +expanse +expansion +expansive +expectant +expedited +expediter +expel +expend +expenses +expensive +expert +expire +expiring +explain +expletive +explicit +explode +exploit +explore +exploring +exponent +exporter +exposable +expose +exposure +express +expulsion +exquisite +extended +extending +extent +extenuate +exterior +external +extinct +extortion +extradite +extras +extrovert +extrude +extruding +exuberant +fable +fabric +fabulous +facebook +facecloth +facedown +faceless +facelift +faceplate +faceted +facial +facility +facing +facsimile +faction +factoid +factor +factsheet +factual +faculty +fade +fading +failing +falcon +fall +false +falsify +fame +familiar +family +famine +famished +fanatic +fancied +fanciness +fancy +fanfare +fang +fanning +fantasize +fantastic +fantasy +fascism +fastball +faster +fasting +fastness +faucet +favorable +favorably +favored +favoring +favorite +fax +feast +federal +fedora +feeble +feed +feel +feisty +feline +felt-tip +feminine +feminism +feminist +feminize +femur +fence +fencing +fender +ferment +fernlike +ferocious +ferocity +ferret +ferris +ferry +fervor +fester +festival +festive +festivity +fetal +fetch +fever +fiber +fiction +fiddle +fiddling +fidelity +fidgeting +fidgety +fifteen +fifth +fiftieth +fifty +figment +figure +figurine +filing +filled +filler +filling +film +filter +filth +filtrate +finale +finalist +finalize +finally +finance +financial +finch +fineness +finer +finicky +finished +finisher +finishing +finite +finless +finlike +fiscally +fit +five +flaccid +flagman +flagpole +flagship +flagstick +flagstone +flail +flakily +flaky +flame +flammable +flanked +flanking +flannels +flap +flaring +flashback +flashbulb +flashcard +flashily +flashing +flashy +flask +flatbed +flatfoot +flatly +flatness +flatten +flattered +flatterer +flattery +flattop +flatware +flatworm +flavored +flavorful +flavoring +flaxseed +fled +fleshed +fleshy +flick +flier +flight +flinch +fling +flint +flip +flirt +float +flock +flogging +flop +floral +florist +floss +flounder +flyable +flyaway +flyer +flying +flyover +flypaper +foam +foe +fog +foil +folic +folk +follicle +follow +fondling +fondly +fondness +fondue +font +food +fool +footage +football +footbath +footboard +footer +footgear +foothill +foothold +footing +footless +footman +footnote +footpad +footpath +footprint +footrest +footsie +footsore +footwear +footwork +fossil +foster +founder +founding +fountain +fox +foyer +fraction +fracture +fragile +fragility +fragment +fragrance +fragrant +frail +frame +framing +frantic +fraternal +frayed +fraying +frays +freckled +freckles +freebase +freebee +freebie +freedom +freefall +freehand +freeing +freeload +freely +freemason +freeness +freestyle +freeware +freeway +freewill +freezable +freezing +freight +french +frenzied +frenzy +frequency +frequent +fresh +fretful +fretted +friction +friday +fridge +fried +friend +frighten +frightful +frigidity +frigidly +frill +fringe +frisbee +frisk +fritter +frivolous +frolic +from +front +frostbite +frosted +frostily +frosting +frostlike +frosty +froth +frown +frozen +fructose +frugality +frugally +fruit +frustrate +frying +gab +gaffe +gag +gainfully +gaining +gains +gala +gallantly +galleria +gallery +galley +gallon +gallows +gallstone +galore +galvanize +gambling +game +gaming +gamma +gander +gangly +gangrene +gangway +gap +garage +garbage +garden +gargle +garland +garlic +garment +garnet +garnish +garter +gas +gatherer +gathering +gating +gauging +gauntlet +gauze +gave +gawk +gazing +gear +gecko +geek +geiger +gem +gender +generic +generous +genetics +genre +gentile +gentleman +gently +gents +geography +geologic +geologist +geology +geometric +geometry +geranium +gerbil +geriatric +germicide +germinate +germless +germproof +gestate +gestation +gesture +getaway +getting +getup +giant +gibberish +giblet +giddily +giddiness +giddy +gift +gigabyte +gigahertz +gigantic +giggle +giggling +giggly +gigolo +gilled +gills +gimmick +girdle +giveaway +given +giver +giving +gizmo +gizzard +glacial +glacier +glade +gladiator +gladly +glamorous +glamour +glance +glancing +glandular +glare +glaring +glass +glaucoma +glazing +gleaming +gleeful +glider +gliding +glimmer +glimpse +glisten +glitch +glitter +glitzy +gloater +gloating +gloomily +gloomy +glorified +glorifier +glorify +glorious +glory +gloss +glove +glowing +glowworm +glucose +glue +gluten +glutinous +glutton +gnarly +gnat +goal +goatskin +goes +goggles +going +goldfish +goldmine +goldsmith +golf +goliath +gonad +gondola +gone +gong +good +gooey +goofball +goofiness +goofy +google +goon +gopher +gore +gorged +gorgeous +gory +gosling +gossip +gothic +gotten +gout +gown +grab +graceful +graceless +gracious +gradation +graded +grader +gradient +grading +gradually +graduate +graffiti +grafted +grafting +grain +granddad +grandkid +grandly +grandma +grandpa +grandson +granite +granny +granola +grant +granular +grape +graph +grapple +grappling +grasp +grass +gratified +gratify +grating +gratitude +gratuity +gravel +graveness +graves +graveyard +gravitate +gravity +gravy +gray +grazing +greasily +greedily +greedless +greedy +green +greeter +greeting +grew +greyhound +grid +grief +grievance +grieving +grievous +grill +grimace +grimacing +grime +griminess +grimy +grinch +grinning +grip +gristle +grit +groggily +groggy +groin +groom +groove +grooving +groovy +grope +ground +grouped +grout +grove +grower +growing +growl +grub +grudge +grudging +grueling +gruffly +grumble +grumbling +grumbly +grumpily +grunge +grunt +guacamole +guidable +guidance +guide +guiding +guileless +guise +gulf +gullible +gully +gulp +gumball +gumdrop +gumminess +gumming +gummy +gurgle +gurgling +guru +gush +gusto +gusty +gutless +guts +gutter +guy +guzzler +gyration +habitable +habitant +habitat +habitual +hacked +hacker +hacking +hacksaw +had +haggler +haiku +half +halogen +halt +halved +halves +hamburger +hamlet +hammock +hamper +hamster +hamstring +handbag +handball +handbook +handbrake +handcart +handclap +handclasp +handcraft +handcuff +handed +handful +handgrip +handgun +handheld +handiness +handiwork +handlebar +handled +handler +handling +handmade +handoff +handpick +handprint +handrail +handsaw +handset +handsfree +handshake +handstand +handwash +handwork +handwoven +handwrite +handyman +hangnail +hangout +hangover +hangup +hankering +hankie +hanky +haphazard +happening +happier +happiest +happily +happiness +happy +harbor +hardcopy +hardcore +hardcover +harddisk +hardened +hardener +hardening +hardhat +hardhead +hardiness +hardly +hardness +hardship +hardware +hardwired +hardwood +hardy +harmful +harmless +harmonica +harmonics +harmonize +harmony +harness +harpist +harsh +harvest +hash +hassle +haste +hastily +hastiness +hasty +hatbox +hatchback +hatchery +hatchet +hatching +hatchling +hate +hatless +hatred +haunt +haven +hazard +hazelnut +hazily +haziness +hazing +hazy +headache +headband +headboard +headcount +headdress +headed +header +headfirst +headgear +heading +headlamp +headless +headlock +headphone +headpiece +headrest +headroom +headscarf +headset +headsman +headstand +headstone +headway +headwear +heap +heat +heave +heavily +heaviness +heaving +hedge +hedging +heftiness +hefty +helium +helmet +helper +helpful +helping +helpless +helpline +hemlock +hemstitch +hence +henchman +henna +herald +herbal +herbicide +herbs +heritage +hermit +heroics +heroism +herring +herself +hertz +hesitancy +hesitant +hesitate +hexagon +hexagram +hubcap +huddle +huddling +huff +hug +hula +hulk +hull +human +humble +humbling +humbly +humid +humiliate +humility +humming +hummus +humongous +humorist +humorless +humorous +humpback +humped +humvee +hunchback +hundredth +hunger +hungrily +hungry +hunk +hunter +hunting +huntress +huntsman +hurdle +hurled +hurler +hurling +hurray +hurricane +hurried +hurry +hurt +husband +hush +husked +huskiness +hut +hybrid +hydrant +hydrated +hydration +hydrogen +hydroxide +hyperlink +hypertext +hyphen +hypnoses +hypnosis +hypnotic +hypnotism +hypnotist +hypnotize +hypocrisy +hypocrite +ibuprofen +ice +iciness +icing +icky +icon +icy +idealism +idealist +idealize +ideally +idealness +identical +identify +identity +ideology +idiocy +idiom +idly +igloo +ignition +ignore +iguana +illicitly +illusion +illusive +image +imaginary +imagines +imaging +imbecile +imitate +imitation +immature +immerse +immersion +imminent +immobile +immodest +immorally +immortal +immovable +immovably +immunity +immunize +impaired +impale +impart +impatient +impeach +impeding +impending +imperfect +imperial +impish +implant +implement +implicate +implicit +implode +implosion +implosive +imply +impolite +important +importer +impose +imposing +impotence +impotency +impotent +impound +imprecise +imprint +imprison +impromptu +improper +improve +improving +improvise +imprudent +impulse +impulsive +impure +impurity +iodine +iodize +ion +ipad +iphone +ipod +irate +irk +iron +irregular +irrigate +irritable +irritably +irritant +irritate +islamic +islamist +isolated +isolating +isolation +isotope +issue +issuing +italicize +italics +item +itinerary +itunes +ivory +ivy +jab +jackal +jacket +jackknife +jackpot +jailbird +jailbreak +jailer +jailhouse +jalapeno +jam +janitor +january +jargon +jarring +jasmine +jaundice +jaunt +java +jawed +jawless +jawline +jaws +jaybird +jaywalker +jazz +jeep +jeeringly +jellied +jelly +jersey +jester +jet +jiffy +jigsaw +jimmy +jingle +jingling +jinx +jitters +jittery +job +jockey +jockstrap +jogger +jogging +john +joining +jokester +jokingly +jolliness +jolly +jolt +jot +jovial +joyfully +joylessly +joyous +joyride +joystick +jubilance +jubilant +judge +judgingly +judicial +judiciary +judo +juggle +juggling +jugular +juice +juiciness +juicy +jujitsu +jukebox +july +jumble +jumbo +jump +junction +juncture +june +junior +juniper +junkie +junkman +junkyard +jurist +juror +jury +justice +justifier +justify +justly +justness +juvenile +kabob +kangaroo +karaoke +karate +karma +kebab +keenly +keenness +keep +keg +kelp +kennel +kept +kerchief +kerosene +kettle +kick +kiln +kilobyte +kilogram +kilometer +kilowatt +kilt +kimono +kindle +kindling +kindly +kindness +kindred +kinetic +kinfolk +king +kinship +kinsman +kinswoman +kissable +kisser +kissing +kitchen +kite +kitten +kitty +kiwi +kleenex +knapsack +knee +knelt +knickers +knoll +koala +kooky +kosher +krypton +kudos +kung +labored +laborer +laboring +laborious +labrador +ladder +ladies +ladle +ladybug +ladylike +lagged +lagging +lagoon +lair +lake +lance +landed +landfall +landfill +landing +landlady +landless +landline +landlord +landmark +landmass +landmine +landowner +landscape +landside +landslide +language +lankiness +lanky +lantern +lapdog +lapel +lapped +lapping +laptop +lard +large +lark +lash +lasso +last +latch +late +lather +latitude +latrine +latter +latticed +launch +launder +laundry +laurel +lavender +lavish +laxative +lazily +laziness +lazy +lecturer +left +legacy +legal +legend +legged +leggings +legible +legibly +legislate +lego +legroom +legume +legwarmer +legwork +lemon +lend +length +lens +lent +leotard +lesser +letdown +lethargic +lethargy +letter +lettuce +level +leverage +levers +levitate +levitator +liability +liable +liberty +librarian +library +licking +licorice +lid +life +lifter +lifting +liftoff +ligament +likely +likeness +likewise +liking +lilac +lilly +lily +limb +limeade +limelight +limes +limit +limping +limpness +line +lingo +linguini +linguist +lining +linked +linoleum +linseed +lint +lion +lip +liquefy +liqueur +liquid +lisp +list +litigate +litigator +litmus +litter +little +livable +lived +lively +liver +livestock +lividly +living +lizard +lubricant +lubricate +lucid +luckily +luckiness +luckless +lucrative +ludicrous +lugged +lukewarm +lullaby +lumber +luminance +luminous +lumpiness +lumping +lumpish +lunacy +lunar +lunchbox +luncheon +lunchroom +lunchtime +lung +lurch +lure +luridness +lurk +lushly +lushness +luster +lustfully +lustily +lustiness +lustrous +lusty +luxurious +luxury +lying +lyrically +lyricism +lyricist +lyrics +macarena +macaroni +macaw +mace +machine +machinist +magazine +magenta +maggot +magical +magician +magma +magnesium +magnetic +magnetism +magnetize +magnifier +magnify +magnitude +magnolia +mahogany +maimed +majestic +majesty +majorette +majority +makeover +maker +makeshift +making +malformed +malt +mama +mammal +mammary +mammogram +manager +managing +manatee +mandarin +mandate +mandatory +mandolin +manger +mangle +mango +mangy +manhandle +manhole +manhood +manhunt +manicotti +manicure +manifesto +manila +mankind +manlike +manliness +manly +manmade +manned +mannish +manor +manpower +mantis +mantra +manual +many +map +marathon +marauding +marbled +marbles +marbling +march +mardi +margarine +margarita +margin +marigold +marina +marine +marital +maritime +marlin +marmalade +maroon +married +marrow +marry +marshland +marshy +marsupial +marvelous +marxism +mascot +masculine +mashed +mashing +massager +masses +massive +mastiff +matador +matchbook +matchbox +matcher +matching +matchless +material +maternal +maternity +math +mating +matriarch +matrimony +matrix +matron +matted +matter +maturely +maturing +maturity +mauve +maverick +maximize +maximum +maybe +mayday +mayflower +moaner +moaning +mobile +mobility +mobilize +mobster +mocha +mocker +mockup +modified +modify +modular +modulator +module +moisten +moistness +moisture +molar +molasses +mold +molecular +molecule +molehill +mollusk +mom +monastery +monday +monetary +monetize +moneybags +moneyless +moneywise +mongoose +mongrel +monitor +monkhood +monogamy +monogram +monologue +monopoly +monorail +monotone +monotype +monoxide +monsieur +monsoon +monstrous +monthly +monument +moocher +moodiness +moody +mooing +moonbeam +mooned +moonlight +moonlike +moonlit +moonrise +moonscape +moonshine +moonstone +moonwalk +mop +morale +morality +morally +morbidity +morbidly +morphine +morphing +morse +mortality +mortally +mortician +mortified +mortify +mortuary +mosaic +mossy +most +mothball +mothproof +motion +motivate +motivator +motive +motocross +motor +motto +mountable +mountain +mounted +mounting +mourner +mournful +mouse +mousiness +moustache +mousy +mouth +movable +move +movie +moving +mower +mowing +much +muck +mud +mug +mulberry +mulch +mule +mulled +mullets +multiple +multiply +multitask +multitude +mumble +mumbling +mumbo +mummified +mummify +mummy +mumps +munchkin +mundane +municipal +muppet +mural +murkiness +murky +murmuring +muscular +museum +mushily +mushiness +mushroom +mushy +music +musket +muskiness +musky +mustang +mustard +muster +mustiness +musty +mutable +mutate +mutation +mute +mutilated +mutilator +mutiny +mutt +mutual +muzzle +myself +myspace +mystified +mystify +myth +nacho +nag +nail +name +naming +nanny +nanometer +nape +napkin +napped +napping +nappy +narrow +nastily +nastiness +national +native +nativity +natural +nature +naturist +nautical +navigate +navigator +navy +nearby +nearest +nearly +nearness +neatly +neatness +nebula +nebulizer +nectar +negate +negation +negative +neglector +negligee +negligent +negotiate +nemeses +nemesis +neon +nephew +nerd +nervous +nervy +nest +net +neurology +neuron +neurosis +neurotic +neuter +neutron +never +next +nibble +nickname +nicotine +niece +nifty +nimble +nimbly +nineteen +ninetieth +ninja +nintendo +ninth +nuclear +nuclei +nucleus +nugget +nullify +number +numbing +numbly +numbness +numeral +numerate +numerator +numeric +numerous +nuptials +nursery +nursing +nurture +nutcase +nutlike +nutmeg +nutrient +nutshell +nuttiness +nutty +nuzzle +nylon +oaf +oak +oasis +oat +obedience +obedient +obituary +object +obligate +obliged +oblivion +oblivious +oblong +obnoxious +oboe +obscure +obscurity +observant +observer +observing +obsessed +obsession +obsessive +obsolete +obstacle +obstinate +obstruct +obtain +obtrusive +obtuse +obvious +occultist +occupancy +occupant +occupier +occupy +ocean +ocelot +octagon +octane +october +octopus +ogle +oil +oink +ointment +okay +old +olive +olympics +omega +omen +ominous +omission +omit +omnivore +onboard +oncoming +ongoing +onion +online +onlooker +only +onscreen +onset +onshore +onslaught +onstage +onto +onward +onyx +oops +ooze +oozy +opacity +opal +open +operable +operate +operating +operation +operative +operator +opium +opossum +opponent +oppose +opposing +opposite +oppressed +oppressor +opt +opulently +osmosis +other +otter +ouch +ought +ounce +outage +outback +outbid +outboard +outbound +outbreak +outburst +outcast +outclass +outcome +outdated +outdoors +outer +outfield +outfit +outflank +outgoing +outgrow +outhouse +outing +outlast +outlet +outline +outlook +outlying +outmatch +outmost +outnumber +outplayed +outpost +outpour +output +outrage +outrank +outreach +outright +outscore +outsell +outshine +outshoot +outsider +outskirts +outsmart +outsource +outspoken +outtakes +outthink +outward +outweigh +outwit +oval +ovary +oven +overact +overall +overarch +overbid +overbill +overbite +overblown +overboard +overbook +overbuilt +overcast +overcoat +overcome +overcook +overcrowd +overdraft +overdrawn +overdress +overdrive +overdue +overeager +overeater +overexert +overfed +overfeed +overfill +overflow +overfull +overgrown +overhand +overhang +overhaul +overhead +overhear +overheat +overhung +overjoyed +overkill +overlabor +overlaid +overlap +overlay +overload +overlook +overlord +overlying +overnight +overpass +overpay +overplant +overplay +overpower +overprice +overrate +overreach +overreact +override +overripe +overrule +overrun +overshoot +overshot +oversight +oversized +oversleep +oversold +overspend +overstate +overstay +overstep +overstock +overstuff +oversweet +overtake +overthrow +overtime +overtly +overtone +overture +overturn +overuse +overvalue +overview +overwrite +owl +oxford +oxidant +oxidation +oxidize +oxidizing +oxygen +oxymoron +oyster +ozone +paced +pacemaker +pacific +pacifier +pacifism +pacifist +pacify +padded +padding +paddle +paddling +padlock +pagan +pager +paging +pajamas +palace +palatable +palm +palpable +palpitate +paltry +pampered +pamperer +pampers +pamphlet +panama +pancake +pancreas +panda +pandemic +pang +panhandle +panic +panning +panorama +panoramic +panther +pantomime +pantry +pants +pantyhose +paparazzi +papaya +paper +paprika +papyrus +parabola +parachute +parade +paradox +paragraph +parakeet +paralegal +paralyses +paralysis +paralyze +paramedic +parameter +paramount +parasail +parasite +parasitic +parcel +parched +parchment +pardon +parish +parka +parking +parkway +parlor +parmesan +parole +parrot +parsley +parsnip +partake +parted +parting +partition +partly +partner +partridge +party +passable +passably +passage +passcode +passenger +passerby +passing +passion +passive +passivism +passover +passport +password +pasta +pasted +pastel +pastime +pastor +pastrami +pasture +pasty +patchwork +patchy +paternal +paternity +path +patience +patient +patio +patriarch +patriot +patrol +patronage +patronize +pauper +pavement +paver +pavestone +pavilion +paving +pawing +payable +payback +paycheck +payday +payee +payer +paying +payment +payphone +payroll +pebble +pebbly +pecan +pectin +peculiar +peddling +pediatric +pedicure +pedigree +pedometer +pegboard +pelican +pellet +pelt +pelvis +penalize +penalty +pencil +pendant +pending +penholder +penknife +pennant +penniless +penny +penpal +pension +pentagon +pentagram +pep +perceive +percent +perch +percolate +perennial +perfected +perfectly +perfume +periscope +perish +perjurer +perjury +perkiness +perky +perm +peroxide +perpetual +perplexed +persecute +persevere +persuaded +persuader +pesky +peso +pessimism +pessimist +pester +pesticide +petal +petite +petition +petri +petroleum +petted +petticoat +pettiness +petty +petunia +phantom +phobia +phoenix +phonebook +phoney +phonics +phoniness +phony +phosphate +photo +phrase +phrasing +placard +placate +placidly +plank +planner +plant +plasma +plaster +plastic +plated +platform +plating +platinum +platonic +platter +platypus +plausible +plausibly +playable +playback +player +playful +playgroup +playhouse +playing +playlist +playmaker +playmate +playoff +playpen +playroom +playset +plaything +playtime +plaza +pleading +pleat +pledge +plentiful +plenty +plethora +plexiglas +pliable +plod +plop +plot +plow +ploy +pluck +plug +plunder +plunging +plural +plus +plutonium +plywood +poach +pod +poem +poet +pogo +pointed +pointer +pointing +pointless +pointy +poise +poison +poker +poking +polar +police +policy +polio +polish +politely +polka +polo +polyester +polygon +polygraph +polymer +poncho +pond +pony +popcorn +pope +poplar +popper +poppy +popsicle +populace +popular +populate +porcupine +pork +porous +porridge +portable +portal +portfolio +porthole +portion +portly +portside +poser +posh +posing +possible +possibly +possum +postage +postal +postbox +postcard +posted +poster +posting +postnasal +posture +postwar +pouch +pounce +pouncing +pound +pouring +pout +powdered +powdering +powdery +power +powwow +pox +praising +prance +prancing +pranker +prankish +prankster +prayer +praying +preacher +preaching +preachy +preamble +precinct +precise +precision +precook +precut +predator +predefine +predict +preface +prefix +preflight +preformed +pregame +pregnancy +pregnant +preheated +prelaunch +prelaw +prelude +premiere +premises +premium +prenatal +preoccupy +preorder +prepaid +prepay +preplan +preppy +preschool +prescribe +preseason +preset +preshow +president +presoak +press +presume +presuming +preteen +pretended +pretender +pretense +pretext +pretty +pretzel +prevail +prevalent +prevent +preview +previous +prewar +prewashed +prideful +pried +primal +primarily +primary +primate +primer +primp +princess +print +prior +prism +prison +prissy +pristine +privacy +private +privatize +prize +proactive +probable +probably +probation +probe +probing +probiotic +problem +procedure +process +proclaim +procreate +procurer +prodigal +prodigy +produce +product +profane +profanity +professed +professor +profile +profound +profusely +progeny +prognosis +program +progress +projector +prologue +prolonged +promenade +prominent +promoter +promotion +prompter +promptly +prone +prong +pronounce +pronto +proofing +proofread +proofs +propeller +properly +property +proponent +proposal +propose +props +prorate +protector +protegee +proton +prototype +protozoan +protract +protrude +proud +provable +proved +proven +provided +provider +providing +province +proving +provoke +provoking +provolone +prowess +prowler +prowling +proximity +proxy +prozac +prude +prudishly +prune +pruning +pry +psychic +public +publisher +pucker +pueblo +pug +pull +pulmonary +pulp +pulsate +pulse +pulverize +puma +pumice +pummel +punch +punctual +punctuate +punctured +pungent +punisher +punk +pupil +puppet +puppy +purchase +pureblood +purebred +purely +pureness +purgatory +purge +purging +purifier +purify +purist +puritan +purity +purple +purplish +purposely +purr +purse +pursuable +pursuant +pursuit +purveyor +pushcart +pushchair +pusher +pushiness +pushing +pushover +pushpin +pushup +pushy +putdown +putt +puzzle +puzzling +pyramid +pyromania +python +quack +quadrant +quail +quaintly +quake +quaking +qualified +qualifier +qualify +quality +qualm +quantum +quarrel +quarry +quartered +quarterly +quarters +quartet +quench +query +quicken +quickly +quickness +quicksand +quickstep +quiet +quill +quilt +quintet +quintuple +quirk +quit +quiver +quizzical +quotable +quotation +quote +rabid +race +racing +racism +rack +racoon +radar +radial +radiance +radiantly +radiated +radiation +radiator +radio +radish +raffle +raft +rage +ragged +raging +ragweed +raider +railcar +railing +railroad +railway +raisin +rake +raking +rally +ramble +rambling +ramp +ramrod +ranch +rancidity +random +ranged +ranger +ranging +ranked +ranking +ransack +ranting +rants +rare +rarity +rascal +rash +rasping +ravage +raven +ravine +raving +ravioli +ravishing +reabsorb +reach +reacquire +reaction +reactive +reactor +reaffirm +ream +reanalyze +reappear +reapply +reappoint +reapprove +rearrange +rearview +reason +reassign +reassure +reattach +reawake +rebalance +rebate +rebel +rebirth +reboot +reborn +rebound +rebuff +rebuild +rebuilt +reburial +rebuttal +recall +recant +recapture +recast +recede +recent +recess +recharger +recipient +recital +recite +reckless +reclaim +recliner +reclining +recluse +reclusive +recognize +recoil +recollect +recolor +reconcile +reconfirm +reconvene +recopy +record +recount +recoup +recovery +recreate +rectal +rectangle +rectified +rectify +recycled +recycler +recycling +reemerge +reenact +reenter +reentry +reexamine +referable +referee +reference +refill +refinance +refined +refinery +refining +refinish +reflected +reflector +reflex +reflux +refocus +refold +reforest +reformat +reformed +reformer +reformist +refract +refrain +refreeze +refresh +refried +refueling +refund +refurbish +refurnish +refusal +refuse +refusing +refutable +refute +regain +regalia +regally +reggae +regime +region +register +registrar +registry +regress +regretful +regroup +regular +regulate +regulator +rehab +reheat +rehire +rehydrate +reimburse +reissue +reiterate +rejoice +rejoicing +rejoin +rekindle +relapse +relapsing +relatable +related +relation +relative +relax +relay +relearn +release +relenting +reliable +reliably +reliance +reliant +relic +relieve +relieving +relight +relish +relive +reload +relocate +relock +reluctant +rely +remake +remark +remarry +rematch +remedial +remedy +remember +reminder +remindful +remission +remix +remnant +remodeler +remold +remorse +remote +removable +removal +removed +remover +removing +rename +renderer +rendering +rendition +renegade +renewable +renewably +renewal +renewed +renounce +renovate +renovator +rentable +rental +rented +renter +reoccupy +reoccur +reopen +reorder +repackage +repacking +repaint +repair +repave +repaying +repayment +repeal +repeated +repeater +repent +rephrase +replace +replay +replica +reply +reporter +repose +repossess +repost +repressed +reprimand +reprint +reprise +reproach +reprocess +reproduce +reprogram +reps +reptile +reptilian +repugnant +repulsion +repulsive +repurpose +reputable +reputably +request +require +requisite +reroute +rerun +resale +resample +rescuer +reseal +research +reselect +reseller +resemble +resend +resent +reset +reshape +reshoot +reshuffle +residence +residency +resident +residual +residue +resigned +resilient +resistant +resisting +resize +resolute +resolved +resonant +resonate +resort +resource +respect +resubmit +result +resume +resupply +resurface +resurrect +retail +retainer +retaining +retake +retaliate +retention +rethink +retinal +retired +retiree +retiring +retold +retool +retorted +retouch +retrace +retract +retrain +retread +retreat +retrial +retrieval +retriever +retry +return +retying +retype +reunion +reunite +reusable +reuse +reveal +reveler +revenge +revenue +reverb +revered +reverence +reverend +reversal +reverse +reversing +reversion +revert +revisable +revise +revision +revisit +revivable +revival +reviver +reviving +revocable +revoke +revolt +revolver +revolving +reward +rewash +rewind +rewire +reword +rework +rewrap +rewrite +rhyme +ribbon +ribcage +rice +riches +richly +richness +rickety +ricotta +riddance +ridden +ride +riding +rifling +rift +rigging +rigid +rigor +rimless +rimmed +rind +rink +rinse +rinsing +riot +ripcord +ripeness +ripening +ripping +ripple +rippling +riptide +rise +rising +risk +risotto +ritalin +ritzy +rival +riverbank +riverbed +riverboat +riverside +riveter +riveting +roamer +roaming +roast +robbing +robe +robin +robotics +robust +rockband +rocker +rocket +rockfish +rockiness +rocking +rocklike +rockslide +rockstar +rocky +rogue +roman +romp +rope +roping +roster +rosy +rotten +rotting +rotunda +roulette +rounding +roundish +roundness +roundup +roundworm +routine +routing +rover +roving +royal +rubbed +rubber +rubbing +rubble +rubdown +ruby +ruckus +rudder +rug +ruined +rule +rumble +rumbling +rummage +rumor +runaround +rundown +runner +running +runny +runt +runway +rupture +rural +ruse +rush +rust +rut +sabbath +sabotage +sacrament +sacred +sacrifice +sadden +saddlebag +saddled +saddling +sadly +sadness +safari +safeguard +safehouse +safely +safeness +saffron +saga +sage +sagging +saggy +said +saint +sake +salad +salami +salaried +salary +saline +salon +saloon +salsa +salt +salutary +salute +salvage +salvaging +salvation +same +sample +sampling +sanction +sanctity +sanctuary +sandal +sandbag +sandbank +sandbar +sandblast +sandbox +sanded +sandfish +sanding +sandlot +sandpaper +sandpit +sandstone +sandstorm +sandworm +sandy +sanitary +sanitizer +sank +santa +sapling +sappiness +sappy +sarcasm +sarcastic +sardine +sash +sasquatch +sassy +satchel +satiable +satin +satirical +satisfied +satisfy +saturate +saturday +sauciness +saucy +sauna +savage +savanna +saved +savings +savior +savor +saxophone +say +scabbed +scabby +scalded +scalding +scale +scaling +scallion +scallop +scalping +scam +scandal +scanner +scanning +scant +scapegoat +scarce +scarcity +scarecrow +scared +scarf +scarily +scariness +scarring +scary +scavenger +scenic +schedule +schematic +scheme +scheming +schilling +schnapps +scholar +science +scientist +scion +scoff +scolding +scone +scoop +scooter +scope +scorch +scorebook +scorecard +scored +scoreless +scorer +scoring +scorn +scorpion +scotch +scoundrel +scoured +scouring +scouting +scouts +scowling +scrabble +scraggly +scrambled +scrambler +scrap +scratch +scrawny +screen +scribble +scribe +scribing +scrimmage +script +scroll +scrooge +scrounger +scrubbed +scrubber +scruffy +scrunch +scrutiny +scuba +scuff +sculptor +sculpture +scurvy +scuttle +secluded +secluding +seclusion +second +secrecy +secret +sectional +sector +secular +securely +security +sedan +sedate +sedation +sedative +sediment +seduce +seducing +segment +seismic +seizing +seldom +selected +selection +selective +selector +self +seltzer +semantic +semester +semicolon +semifinal +seminar +semisoft +semisweet +senate +senator +send +senior +senorita +sensation +sensitive +sensitize +sensually +sensuous +sepia +september +septic +septum +sequel +sequence +sequester +series +sermon +serotonin +serpent +serrated +serve +service +serving +sesame +sessions +setback +setting +settle +settling +setup +sevenfold +seventeen +seventh +seventy +severity +shabby +shack +shaded +shadily +shadiness +shading +shadow +shady +shaft +shakable +shakily +shakiness +shaking +shaky +shale +shallot +shallow +shame +shampoo +shamrock +shank +shanty +shape +shaping +share +sharpener +sharper +sharpie +sharply +sharpness +shawl +sheath +shed +sheep +sheet +shelf +shell +shelter +shelve +shelving +sherry +shield +shifter +shifting +shiftless +shifty +shimmer +shimmy +shindig +shine +shingle +shininess +shining +shiny +ship +shirt +shivering +shock +shone +shoplift +shopper +shopping +shoptalk +shore +shortage +shortcake +shortcut +shorten +shorter +shorthand +shortlist +shortly +shortness +shorts +shortwave +shorty +shout +shove +showbiz +showcase +showdown +shower +showgirl +showing +showman +shown +showoff +showpiece +showplace +showroom +showy +shrank +shrapnel +shredder +shredding +shrewdly +shriek +shrill +shrimp +shrine +shrink +shrivel +shrouded +shrubbery +shrubs +shrug +shrunk +shucking +shudder +shuffle +shuffling +shun +shush +shut +shy +siamese +siberian +sibling +siding +sierra +siesta +sift +sighing +silenced +silencer +silent +silica +silicon +silk +silliness +silly +silo +silt +silver +similarly +simile +simmering +simple +simplify +simply +sincere +sincerity +singer +singing +single +singular +sinister +sinless +sinner +sinuous +sip +siren +sister +sitcom +sitter +sitting +situated +situation +sixfold +sixteen +sixth +sixties +sixtieth +sixtyfold +sizable +sizably +size +sizing +sizzle +sizzling +skater +skating +skedaddle +skeletal +skeleton +skeptic +sketch +skewed +skewer +skid +skied +skier +skies +skiing +skilled +skillet +skillful +skimmed +skimmer +skimming +skimpily +skincare +skinhead +skinless +skinning +skinny +skintight +skipper +skipping +skirmish +skirt +skittle +skydiver +skylight +skyline +skype +skyrocket +skyward +slab +slacked +slacker +slacking +slackness +slacks +slain +slam +slander +slang +slapping +slapstick +slashed +slashing +slate +slather +slaw +sled +sleek +sleep +sleet +sleeve +slept +sliceable +sliced +slicer +slicing +slick +slider +slideshow +sliding +slighted +slighting +slightly +slimness +slimy +slinging +slingshot +slinky +slip +slit +sliver +slobbery +slogan +sloped +sloping +sloppily +sloppy +slot +slouching +slouchy +sludge +slug +slum +slurp +slush +sly +small +smartly +smartness +smasher +smashing +smashup +smell +smelting +smile +smilingly +smirk +smite +smith +smitten +smock +smog +smoked +smokeless +smokiness +smoking +smoky +smolder +smooth +smother +smudge +smudgy +smuggler +smuggling +smugly +smugness +snack +snagged +snaking +snap +snare +snarl +snazzy +sneak +sneer +sneeze +sneezing +snide +sniff +snippet +snipping +snitch +snooper +snooze +snore +snoring +snorkel +snort +snout +snowbird +snowboard +snowbound +snowcap +snowdrift +snowdrop +snowfall +snowfield +snowflake +snowiness +snowless +snowman +snowplow +snowshoe +snowstorm +snowsuit +snowy +snub +snuff +snuggle +snugly +snugness +speak +spearfish +spearhead +spearman +spearmint +species +specimen +specked +speckled +specks +spectacle +spectator +spectrum +speculate +speech +speed +spellbind +speller +spelling +spendable +spender +spending +spent +spew +sphere +spherical +sphinx +spider +spied +spiffy +spill +spilt +spinach +spinal +spindle +spinner +spinning +spinout +spinster +spiny +spiral +spirited +spiritism +spirits +spiritual +splashed +splashing +splashy +splatter +spleen +splendid +splendor +splice +splicing +splinter +splotchy +splurge +spoilage +spoiled +spoiler +spoiling +spoils +spoken +spokesman +sponge +spongy +sponsor +spoof +spookily +spooky +spool +spoon +spore +sporting +sports +sporty +spotless +spotlight +spotted +spotter +spotting +spotty +spousal +spouse +spout +sprain +sprang +sprawl +spray +spree +sprig +spring +sprinkled +sprinkler +sprint +sprite +sprout +spruce +sprung +spry +spud +spur +sputter +spyglass +squabble +squad +squall +squander +squash +squatted +squatter +squatting +squeak +squealer +squealing +squeamish +squeegee +squeeze +squeezing +squid +squiggle +squiggly +squint +squire +squirt +squishier +squishy +stability +stabilize +stable +stack +stadium +staff +stage +staging +stagnant +stagnate +stainable +stained +staining +stainless +stalemate +staleness +stalling +stallion +stamina +stammer +stamp +stand +stank +staple +stapling +starboard +starch +stardom +stardust +starfish +stargazer +staring +stark +starless +starlet +starlight +starlit +starring +starry +starship +starter +starting +startle +startling +startup +starved +starving +stash +state +static +statistic +statue +stature +status +statute +statutory +staunch +stays +steadfast +steadier +steadily +steadying +steam +steed +steep +steerable +steering +steersman +stegosaur +stellar +stem +stench +stencil +step +stereo +sterile +sterility +sterilize +sterling +sternness +sternum +stew +stick +stiffen +stiffly +stiffness +stifle +stifling +stillness +stilt +stimulant +stimulate +stimuli +stimulus +stinger +stingily +stinging +stingray +stingy +stinking +stinky +stipend +stipulate +stir +stitch +stock +stoic +stoke +stole +stomp +stonewall +stoneware +stonework +stoning +stony +stood +stooge +stool +stoop +stoplight +stoppable +stoppage +stopped +stopper +stopping +stopwatch +storable +storage +storeroom +storewide +storm +stout +stove +stowaway +stowing +straddle +straggler +strained +strainer +straining +strangely +stranger +strangle +strategic +strategy +stratus +straw +stray +streak +stream +street +strength +strenuous +strep +stress +stretch +strewn +stricken +strict +stride +strife +strike +striking +strive +striving +strobe +strode +stroller +strongbox +strongly +strongman +struck +structure +strudel +struggle +strum +strung +strut +stubbed +stubble +stubbly +stubborn +stucco +stuck +student +studied +studio +study +stuffed +stuffing +stuffy +stumble +stumbling +stump +stung +stunned +stunner +stunning +stunt +stupor +sturdily +sturdy +styling +stylishly +stylist +stylized +stylus +suave +subarctic +subatomic +subdivide +subdued +subduing +subfloor +subgroup +subheader +subject +sublease +sublet +sublevel +sublime +submarine +submerge +submersed +submitter +subpanel +subpar +subplot +subprime +subscribe +subscript +subsector +subside +subsiding +subsidize +subsidy +subsoil +subsonic +substance +subsystem +subtext +subtitle +subtly +subtotal +subtract +subtype +suburb +subway +subwoofer +subzero +succulent +such +suction +sudden +sudoku +suds +sufferer +suffering +suffice +suffix +suffocate +suffrage +sugar +suggest +suing +suitable +suitably +suitcase +suitor +sulfate +sulfide +sulfite +sulfur +sulk +sullen +sulphate +sulphuric +sultry +superbowl +superglue +superhero +superior +superjet +superman +supermom +supernova +supervise +supper +supplier +supply +support +supremacy +supreme +surcharge +surely +sureness +surface +surfacing +surfboard +surfer +surgery +surgical +surging +surname +surpass +surplus +surprise +surreal +surrender +surrogate +surround +survey +survival +survive +surviving +survivor +sushi +suspect +suspend +suspense +sustained +sustainer +swab +swaddling +swagger +swampland +swan +swapping +swarm +sway +swear +sweat +sweep +swell +swept +swerve +swifter +swiftly +swiftness +swimmable +swimmer +swimming +swimsuit +swimwear +swinger +swinging +swipe +swirl +switch +swivel +swizzle +swooned +swoop +swoosh +swore +sworn +swung +sycamore +sympathy +symphonic +symphony +symptom +synapse +syndrome +synergy +synopses +synopsis +synthesis +synthetic +syrup +system +t-shirt +tabasco +tabby +tableful +tables +tablet +tableware +tabloid +tackiness +tacking +tackle +tackling +tacky +taco +tactful +tactical +tactics +tactile +tactless +tadpole +taekwondo +tag +tainted +take +taking +talcum +talisman +tall +talon +tamale +tameness +tamer +tamper +tank +tanned +tannery +tanning +tantrum +tapeless +tapered +tapering +tapestry +tapioca +tapping +taps +tarantula +target +tarmac +tarnish +tarot +tartar +tartly +tartness +task +tassel +taste +tastiness +tasting +tasty +tattered +tattle +tattling +tattoo +taunt +tavern +thank +that +thaw +theater +theatrics +thee +theft +theme +theology +theorize +thermal +thermos +thesaurus +these +thesis +thespian +thicken +thicket +thickness +thieving +thievish +thigh +thimble +thing +think +thinly +thinner +thinness +thinning +thirstily +thirsting +thirsty +thirteen +thirty +thong +thorn +those +thousand +thrash +thread +threaten +threefold +thrift +thrill +thrive +thriving +throat +throbbing +throng +throttle +throwaway +throwback +thrower +throwing +thud +thumb +thumping +thursday +thus +thwarting +thyself +tiara +tibia +tidal +tidbit +tidiness +tidings +tidy +tiger +tighten +tightly +tightness +tightrope +tightwad +tigress +tile +tiling +till +tilt +timid +timing +timothy +tinderbox +tinfoil +tingle +tingling +tingly +tinker +tinkling +tinsel +tinsmith +tint +tinwork +tiny +tipoff +tipped +tipper +tipping +tiptoeing +tiptop +tiring +tissue +trace +tracing +track +traction +tractor +trade +trading +tradition +traffic +tragedy +trailing +trailside +train +traitor +trance +tranquil +transfer +transform +translate +transpire +transport +transpose +trapdoor +trapeze +trapezoid +trapped +trapper +trapping +traps +trash +travel +traverse +travesty +tray +treachery +treading +treadmill +treason +treat +treble +tree +trekker +tremble +trembling +tremor +trench +trend +trespass +triage +trial +triangle +tribesman +tribunal +tribune +tributary +tribute +triceps +trickery +trickily +tricking +trickle +trickster +tricky +tricolor +tricycle +trident +tried +trifle +trifocals +trillion +trilogy +trimester +trimmer +trimming +trimness +trinity +trio +tripod +tripping +triumph +trivial +trodden +trolling +trombone +trophy +tropical +tropics +trouble +troubling +trough +trousers +trout +trowel +truce +truck +truffle +trump +trunks +trustable +trustee +trustful +trusting +trustless +truth +try +tubby +tubeless +tubular +tucking +tuesday +tug +tuition +tulip +tumble +tumbling +tummy +turban +turbine +turbofan +turbojet +turbulent +turf +turkey +turmoil +turret +turtle +tusk +tutor +tutu +tux +tweak +tweed +tweet +tweezers +twelve +twentieth +twenty +twerp +twice +twiddle +twiddling +twig +twilight +twine +twins +twirl +twistable +twisted +twister +twisting +twisty +twitch +twitter +tycoon +tying +tyke +udder +ultimate +ultimatum +ultra +umbilical +umbrella +umpire +unabashed +unable +unadorned +unadvised +unafraid +unaired +unaligned +unaltered +unarmored +unashamed +unaudited +unawake +unaware +unbaked +unbalance +unbeaten +unbend +unbent +unbiased +unbitten +unblended +unblessed +unblock +unbolted +unbounded +unboxed +unbraided +unbridle +unbroken +unbuckled +unbundle +unburned +unbutton +uncanny +uncapped +uncaring +uncertain +unchain +unchanged +uncharted +uncheck +uncivil +unclad +unclaimed +unclamped +unclasp +uncle +unclip +uncloak +unclog +unclothed +uncoated +uncoiled +uncolored +uncombed +uncommon +uncooked +uncork +uncorrupt +uncounted +uncouple +uncouth +uncover +uncross +uncrown +uncrushed +uncured +uncurious +uncurled +uncut +undamaged +undated +undaunted +undead +undecided +undefined +underage +underarm +undercoat +undercook +undercut +underdog +underdone +underfed +underfeed +underfoot +undergo +undergrad +underhand +underline +underling +undermine +undermost +underpaid +underpass +underpay +underrate +undertake +undertone +undertook +undertow +underuse +underwear +underwent +underwire +undesired +undiluted +undivided +undocked +undoing +undone +undrafted +undress +undrilled +undusted +undying +unearned +unearth +unease +uneasily +uneasy +uneatable +uneaten +unedited +unelected +unending +unengaged +unenvied +unequal +unethical +uneven +unexpired +unexposed +unfailing +unfair +unfasten +unfazed +unfeeling +unfiled +unfilled +unfitted +unfitting +unfixable +unfixed +unflawed +unfocused +unfold +unfounded +unframed +unfreeze +unfrosted +unfrozen +unfunded +unglazed +ungloved +unglue +ungodly +ungraded +ungreased +unguarded +unguided +unhappily +unhappy +unharmed +unhealthy +unheard +unhearing +unheated +unhelpful +unhidden +unhinge +unhitched +unholy +unhook +unicorn +unicycle +unified +unifier +uniformed +uniformly +unify +unimpeded +uninjured +uninstall +uninsured +uninvited +union +uniquely +unisexual +unison +unissued +unit +universal +universe +unjustly +unkempt +unkind +unknotted +unknowing +unknown +unlaced +unlatch +unlawful +unleaded +unlearned +unleash +unless +unleveled +unlighted +unlikable +unlimited +unlined +unlinked +unlisted +unlit +unlivable +unloaded +unloader +unlocked +unlocking +unlovable +unloved +unlovely +unloving +unluckily +unlucky +unmade +unmanaged +unmanned +unmapped +unmarked +unmasked +unmasking +unmatched +unmindful +unmixable +unmixed +unmolded +unmoral +unmovable +unmoved +unmoving +unnamable +unnamed +unnatural +unneeded +unnerve +unnerving +unnoticed +unopened +unopposed +unpack +unpadded +unpaid +unpainted +unpaired +unpaved +unpeeled +unpicked +unpiloted +unpinned +unplanned +unplanted +unpleased +unpledged +unplowed +unplug +unpopular +unproven +unquote +unranked +unrated +unraveled +unreached +unread +unreal +unreeling +unrefined +unrelated +unrented +unrest +unretired +unrevised +unrigged +unripe +unrivaled +unroasted +unrobed +unroll +unruffled +unruly +unrushed +unsaddle +unsafe +unsaid +unsalted +unsaved +unsavory +unscathed +unscented +unscrew +unsealed +unseated +unsecured +unseeing +unseemly +unseen +unselect +unselfish +unsent +unsettled +unshackle +unshaken +unshaved +unshaven +unsheathe +unshipped +unsightly +unsigned +unskilled +unsliced +unsmooth +unsnap +unsocial +unsoiled +unsold +unsolved +unsorted +unspoiled +unspoken +unstable +unstaffed +unstamped +unsteady +unsterile +unstirred +unstitch +unstopped +unstuck +unstuffed +unstylish +unsubtle +unsubtly +unsuited +unsure +unsworn +untagged +untainted +untaken +untamed +untangled +untapped +untaxed +unthawed +unthread +untidy +untie +until +untimed +untimely +untitled +untoasted +untold +untouched +untracked +untrained +untreated +untried +untrimmed +untrue +untruth +unturned +untwist +untying +unusable +unused +unusual +unvalued +unvaried +unvarying +unveiled +unveiling +unvented +unviable +unvisited +unvocal +unwanted +unwarlike +unwary +unwashed +unwatched +unweave +unwed +unwelcome +unwell +unwieldy +unwilling +unwind +unwired +unwitting +unwomanly +unworldly +unworn +unworried +unworthy +unwound +unwoven +unwrapped +unwritten +unzip +upbeat +upchuck +upcoming +upcountry +update +upfront +upgrade +upheaval +upheld +uphill +uphold +uplifted +uplifting +upload +upon +upper +upright +uprising +upriver +uproar +uproot +upscale +upside +upstage +upstairs +upstart +upstate +upstream +upstroke +upswing +uptake +uptight +uptown +upturned +upward +upwind +uranium +urban +urchin +urethane +urgency +urgent +urging +urologist +urology +usable +usage +useable +used +uselessly +user +usher +usual +utensil +utility +utilize +utmost +utopia +utter +vacancy +vacant +vacate +vacation +vagabond +vagrancy +vagrantly +vaguely +vagueness +valiant +valid +valium +valley +valuables +value +vanilla +vanish +vanity +vanquish +vantage +vaporizer +variable +variably +varied +variety +various +varmint +varnish +varsity +varying +vascular +vaseline +vastly +vastness +veal +vegan +veggie +vehicular +velcro +velocity +velvet +vendetta +vending +vendor +veneering +vengeful +venomous +ventricle +venture +venue +venus +verbalize +verbally +verbose +verdict +verify +verse +version +versus +vertebrae +vertical +vertigo +very +vessel +vest +veteran +veto +vexingly +viability +viable +vibes +vice +vicinity +victory +video +viewable +viewer +viewing +viewless +viewpoint +vigorous +village +villain +vindicate +vineyard +vintage +violate +violation +violator +violet +violin +viper +viral +virtual +virtuous +virus +visa +viscosity +viscous +viselike +visible +visibly +vision +visiting +visitor +visor +vista +vitality +vitalize +vitally +vitamins +vivacious +vividly +vividness +vixen +vocalist +vocalize +vocally +vocation +voice +voicing +void +volatile +volley +voltage +volumes +voter +voting +voucher +vowed +vowel +voyage +wackiness +wad +wafer +waffle +waged +wager +wages +waggle +wagon +wake +waking +walk +walmart +walnut +walrus +waltz +wand +wannabe +wanted +wanting +wasabi +washable +washbasin +washboard +washbowl +washcloth +washday +washed +washer +washhouse +washing +washout +washroom +washstand +washtub +wasp +wasting +watch +water +waviness +waving +wavy +whacking +whacky +wham +wharf +wheat +whenever +whiff +whimsical +whinny +whiny +whisking +whoever +whole +whomever +whoopee +whooping +whoops +why +wick +widely +widen +widget +widow +width +wieldable +wielder +wife +wifi +wikipedia +wildcard +wildcat +wilder +wildfire +wildfowl +wildland +wildlife +wildly +wildness +willed +willfully +willing +willow +willpower +wilt +wimp +wince +wincing +wind +wing +winking +winner +winnings +winter +wipe +wired +wireless +wiring +wiry +wisdom +wise +wish +wisplike +wispy +wistful +wizard +wobble +wobbling +wobbly +wok +wolf +wolverine +womanhood +womankind +womanless +womanlike +womanly +womb +woof +wooing +wool +woozy +word +work +worried +worrier +worrisome +worry +worsening +worshiper +worst +wound +woven +wow +wrangle +wrath +wreath +wreckage +wrecker +wrecking +wrench +wriggle +wriggly +wrinkle +wrinkly +wrist +writing +written +wrongdoer +wronged +wrongful +wrongly +wrongness +wrought +xbox +xerox +yahoo +yam +yanking +yapping +yard +yarn +yeah +yearbook +yearling +yearly +yearning +yeast +yelling +yelp +yen +yesterday +yiddish +yield +yin +yippee +yo-yo +yodel +yoga +yogurt +yonder +yoyo +yummy +zap +zealous +zebra +zen +zeppelin +zero +zestfully +zesty +zigzagged +zipfile +zipping +zippy +zips +zit +zodiac +zombie +zone +zoning +zookeeper +zoologist +zoology +zoom diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 08a56e145..7e4695aa4 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -82,6 +82,19 @@ end end + describe 'check_in_code' do + it 'generates a check_in_code on create' do + event = Fabricate(:event) + expect(event.check_in_code).to be_present + expect(event.check_in_code.split('-').length).to eq(3) + end + + it 'generates a unique check_in_code' do + codes = 3.times.map { Fabricate(:event).check_in_code } + expect(codes.uniq.length).to eq(3) + end + end + context '#verified_students' do it 'returns all students who have verified their attendance' do event = Fabricate(:event) diff --git a/spec/models/workshop_spec.rb b/spec/models/workshop_spec.rb index 7fbc22bf1..2a41d0958 100644 --- a/spec/models/workshop_spec.rb +++ b/spec/models/workshop_spec.rb @@ -210,4 +210,17 @@ expect(workshop.invitable_yet?).to be false end end + + describe 'check_in_code' do + it 'generates a check_in_code on create' do + workshop = Fabricate(:workshop) + expect(workshop.check_in_code).to be_present + expect(workshop.check_in_code.split('-').length).to eq(3) + end + + it 'generates a unique check_in_code' do + codes = 3.times.map { Fabricate(:workshop).check_in_code } + expect(codes.uniq.length).to eq(3) + end + end end From 7e5f038bac499bedbbaa92474628a0493e783d83 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:34:59 +0200 Subject: [PATCH 03/26] feat: add CheckInPdf service for landscape QR check-in PDF --- Gemfile | 2 ++ Gemfile.lock | 18 ++++++++++ app/services/check_in_pdf.rb | 54 ++++++++++++++++++++++++++++++ config/environments/test.rb | 3 ++ config/routes.rb | 16 +++++++++ spec/services/check_in_pdf_spec.rb | 18 ++++++++++ 6 files changed, 111 insertions(+) create mode 100644 app/services/check_in_pdf.rb create mode 100644 spec/services/check_in_pdf_spec.rb diff --git a/Gemfile b/Gemfile index b1f0e946d..ba1ef303e 100644 --- a/Gemfile +++ b/Gemfile @@ -126,6 +126,8 @@ end gem 'rollbar' gem 'scout_apm' +gem 'rqrcode' +gem 'prawn' gem 'carrierwave-aws', '~> 1.6' gem 'sitemap_generator', '~> 7.0' diff --git a/Gemfile.lock b/Gemfile.lock index 0b547b194..0f45568ec 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -149,6 +149,7 @@ GEM carrierwave (>= 2.0, < 4) childprocess (5.1.0) logger (~> 1.5) + chunky_png (1.4.0) cocoon (1.2.15) coderay (1.1.3) coffee-script (2.4.1) @@ -351,6 +352,7 @@ GEM parser (3.3.11.1) ast (~> 2.4.1) racc + pdf-core (0.9.0) pg (1.6.3-aarch64-linux) pg (1.6.3-arm64-darwin) pg (1.6.3-x86_64-darwin) @@ -364,6 +366,9 @@ GEM popper_js (2.11.8) pp (0.6.3) prettyprint + prawn (2.4.0) + pdf-core (~> 0.9.0) + ttfunk (~> 1.7) premailer (1.29.0) addressable css_parser (>= 1.19.0) @@ -464,6 +469,10 @@ GEM rolify (6.0.1) rollbar (3.8.0) rouge (4.1.3) + rqrcode (3.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.1.0) rspec-collection_matchers (1.2.1) rspec-expectations (>= 2.99.0.beta1) rspec-core (3.13.6) @@ -574,6 +583,7 @@ GEM tilt (2.7.0) timeout (0.6.1) tsort (0.2.0) + ttfunk (1.7.0) turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) @@ -668,6 +678,7 @@ DEPENDENCIES parallel_tests pg pickadate-rails + prawn premailer-rails pry-byebug pry-rails @@ -682,6 +693,7 @@ DEPENDENCIES reline rolify rollbar + rqrcode rspec-collection_matchers rspec-rails rubocop @@ -747,6 +759,7 @@ CHECKSUMS carrierwave (3.1.3) sha256=b99324c5ea63c55ce0776bc0e997c1c70cdd3a490a7458d23a8978930c76541a carrierwave-aws (1.6.1) sha256=ad9eb8cc677c0b9371bd3534b53689ca731ddc6d04fe764e131e298f36adb13b childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec + chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe cocoon (1.2.15) sha256=d08f14e69653287d7a060ee43389b8c824e55191dffbca0c5c586f38ef491f0d coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b coffee-script (2.4.1) sha256=82fe281e11b93c8117b98c5ea8063e71741870f1c4fbb27177d7d6333dd38765 @@ -843,6 +856,7 @@ CHECKSUMS parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pdf-core (0.9.0) sha256=4f368b2f12b57ec979872d4bf4bd1a67e8648e0c81ab89801431d2fc89f4e0bb pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 @@ -851,6 +865,7 @@ CHECKSUMS playwright-ruby-client (1.59.0) sha256=b9e66233ed7b83e917980badc27ecd233e244503c74772a3854dbd5d41438c21 popper_js (2.11.8) sha256=f4b0be717fc0d50bdb3dbbc55788525a9e0e8f640b76c9971fc34ee609eadbd2 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prawn (2.4.0) sha256=82062744f7126c2d77501da253a154271790254dfa8c309b8e52e79bc5de2abd premailer (1.29.0) sha256=015f30c520701f3d47fd898886a3eaf4e5171efcc5fd239b872efd1ba61d3da0 premailer-rails (1.12.0) sha256=c13815d161b9bc7f7d3d81396b0bb0a61a90fa9bd89931548bf4e537c7710400 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 @@ -887,6 +902,8 @@ CHECKSUMS rolify (6.0.1) sha256=fe45fbf70c4033ccada6f9ccc02b946eeaad044ea5d3e38546ae408d3adeb5b6 rollbar (3.8.0) sha256=655d2783c84c854634f4a1045f34ff8d390596ee4c481f01fe1b214423047a3e rouge (4.1.3) sha256=9c8663db26e05e52b3b0286daacae73ebb361c1bd31d7febd8c57087faa0b9a5 + rqrcode (3.2.0) sha256=64c1494ca6bb67d731330f38b50e3fd09eeab4f5dcd04b608e21218d1d0b9542 + rqrcode_core (2.1.0) sha256=f303b85df89c1b8fc5ee8dc19808c9dc4330e6329b660d99d4a8cbb36ca13051 rspec-collection_matchers (1.2.1) sha256=ff7626d2bbf16ac237fbb46439694c78b7d9c6dc49c4b216df437968133b56a2 rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 @@ -929,6 +946,7 @@ CHECKSUMS tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + ttfunk (1.7.0) sha256=2370ba484b1891c70bdcafd3448cfd82a32dd794802d81d720a64c15d3ef2a96 turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b tzinfo-data (1.2026.2) sha256=7db0d3d3d53b8d7601fc183fccc8c6d056a3004e14eb59ea995bf6aec4ae10bc diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb new file mode 100644 index 000000000..249903c17 --- /dev/null +++ b/app/services/check_in_pdf.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class CheckInPdf + def initialize(parent) + @parent = parent + end + + def render + Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 20) do |pdf| + pdf.text "codebar", size: 16, color: "333333" + pdf.move_down 8 + + pdf.text @parent.to_s, size: 28, style: :bold + pdf.move_down 4 + pdf.text formatted_date, size: 16, color: "555555" + pdf.move_down 8 + + if @parent.respond_to?(:venue) && @parent.venue.present? + venue = @parent.venue + pdf.text venue.name, size: 14, color: "555555" + if venue.respond_to?(:address) && venue.address.present? + pdf.text venue.address.to_s, size: 12, color: "777777" + end + pdf.move_down 4 + end + + if @parent.respond_to?(:sponsors) && @parent.sponsors.any? + pdf.text "Sponsored by: #{@parent.sponsors.map(&:name).join(', ')}", size: 12, color: "777777" + pdf.move_down 8 + end + + qrcode = RQRCode::QRCode.new(@parent.check_in_url) + png = qrcode.as_png(module_size: 6) + qr_width = 160 + + pdf.bounding_box([(pdf.bounds.width - qr_width) / 2.0, pdf.cursor], + width: qr_width, height: pdf.cursor - 20) do + pdf.image StringIO.new(png.to_blob), width: qr_width, position: :center + pdf.move_down 4 + pdf.text "Scan to check in", size: 11, align: :center, color: "999999" + pdf.move_down 2 + pdf.text @parent.check_in_url.sub(%r{^https?://}, ""), + size: 10, align: :center, color: "999999" + end + end.render + end + + private + + def formatted_date + dt = @parent.date_and_time + dt.strftime("%A, %B %d, %Y at %H:%M") + end +end diff --git a/config/environments/test.rb b/config/environments/test.rb index c66414de3..9b2aba52d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -61,5 +61,8 @@ Bullet.enable = true Bullet.bullet_logger = true Bullet.raise = false # raise an error if n+1 query occurs + + # Ensure route url_helpers (e.g. CheckInCode#check_in_url) work in service specs + Rails.application.routes.default_url_options = { host: 'localhost:3000' } end end diff --git a/config/routes.rb b/config/routes.rb index bc8239af1..1264770f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -162,6 +162,14 @@ get 'results' end end + + resources :events, only: [] do + resource :check_in, only: [:show], controller: "check_ins" + end + + resources :workshops, only: [] do + resource :check_in, only: [:show], controller: "check_ins" + end end get '/login', to: 'auth_services#new' @@ -174,6 +182,14 @@ resources :donations, only: %i[new] resources :payments, only: %i[new create] + # Public check-in routes + get "check-in/e/:code" => "check_ins#new", as: :check_in_e + post "check-in/e/:code" => "check_ins#create" + get "check-in/e/:code/confirm" => "check_ins#confirm", as: :check_in_e_confirm + get "check-in/w/:code" => "check_ins#new", as: :check_in_w + post "check-in/w/:code" => "check_ins#create" + get "check-in/w/:code/confirm" => "check_ins#confirm", as: :check_in_w_confirm + get 'cookie-policy' => 'pages#show', id: 'cookie-policy' get 'privacy-policy' => 'pages#show', id: 'privacy-policy' get 'breach-code-of-conduct' => 'pages#show', id: 'breach-code-of-conduct' diff --git a/spec/services/check_in_pdf_spec.rb b/spec/services/check_in_pdf_spec.rb new file mode 100644 index 000000000..5a7358883 --- /dev/null +++ b/spec/services/check_in_pdf_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CheckInPdf do + let(:event) { Fabricate(:event) } + + subject { described_class.new(event) } + + it "generates a PDF" do + pdf = subject.render + expect(pdf).to start_with("%PDF") + end + + it "renders without error" do + expect { subject.render }.not_to raise_error + end +end From d678292e04b9e94c7ba652b123806d044938b266 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:36:36 +0200 Subject: [PATCH 04/26] feat: add admin check-in controller with instructions and PDF download --- app/controllers/admin/check_ins_controller.rb | 31 +++++++++++++++++++ app/views/admin/check_ins/show.html.haml | 31 +++++++++++++++++++ .../admin/check_ins_controller_spec.rb | 22 +++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 app/controllers/admin/check_ins_controller.rb create mode 100644 app/views/admin/check_ins/show.html.haml create mode 100644 spec/controllers/admin/check_ins_controller_spec.rb diff --git a/app/controllers/admin/check_ins_controller.rb b/app/controllers/admin/check_ins_controller.rb new file mode 100644 index 000000000..ff3eb77af --- /dev/null +++ b/app/controllers/admin/check_ins_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Admin::CheckInsController < Admin::ApplicationController + before_action :load_parent + + def show + authorize @parent + @parent.generate_check_in_code! if @parent.check_in_code.blank? + + respond_to do |format| + format.html + format.pdf do + pdf = CheckInPdf.new(@parent).render + send_data pdf, + filename: "check-in-#{@parent.to_param}.pdf", + type: "application/pdf", + disposition: "attachment" + end + end + end + + private + + def load_parent + if params[:event_id] + @parent = Event.find_by!(slug: params[:event_id]) + elsif params[:workshop_id] + @parent = Workshop.find(params[:workshop_id]) + end + end +end diff --git a/app/views/admin/check_ins/show.html.haml b/app/views/admin/check_ins/show.html.haml new file mode 100644 index 000000000..e38708bbd --- /dev/null +++ b/app/views/admin/check_ins/show.html.haml @@ -0,0 +1,31 @@ +-# app/views/admin/check_ins/show.html.haml + +%h2 Check-in for #{@parent.to_s} + +.alert.alert-info + %p.mb-0 + Let attendees mark themselves as attended by scanning the QR code on the PDF. + Once scanned, they sign in with GitHub and confirm their role. + You'll see their attendance update in the event admin page. + +.card.mb-4 + .card-body + %h5.card-title Check-in URL + %p.lead.mb-1= @parent.check_in_code + %p.text-muted.small= @parent.check_in_url + %p.text-muted.small Share this URL with anyone who can't scan the QR code. + +.card.mb-4 + .card-body + %h5.card-title How to use + %ol + %li Download the PDF below. + %li Print it or display it on a screen at the venue entrance. + %li Attendees scan the QR code with their phone. + %li They sign in with GitHub and select their role (Student/Coach). + %li They're checked in! You can see attendance on the event/workshop admin page. + += link_to url_for([:admin, @parent, :check_in, format: :pdf]), + class: 'btn btn-primary btn-lg' do + %i.fas.fa-download + Download PDF diff --git a/spec/controllers/admin/check_ins_controller_spec.rb b/spec/controllers/admin/check_ins_controller_spec.rb new file mode 100644 index 000000000..586fbbc54 --- /dev/null +++ b/spec/controllers/admin/check_ins_controller_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::CheckInsController do + let(:admin) { Fabricate(:member) } + let(:event) { Fabricate(:event) } + + before { login_as_admin(admin) } + + describe "GET show" do + it "renders the instructions page" do + get :show, params: { event_id: event.slug } + expect(response).to be_successful + end + + it "returns PDF for .pdf format" do + get :show, params: { event_id: event.slug, format: :pdf } + expect(response.content_type).to eq("application/pdf") + end + end +end From 15bf5acad10993b191c38348e1afbdd0cb1ad432 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:37:25 +0200 Subject: [PATCH 05/26] feat: add Check-in button to admin event/workshop toolbar --- app/views/admin/events/show.html.haml | 3 +++ app/views/admin/workshops/show.html.haml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/app/views/admin/events/show.html.haml b/app/views/admin/events/show.html.haml index 479c27243..557c2ea4c 100644 --- a/app/views/admin/events/show.html.haml +++ b/app/views/admin/events/show.html.haml @@ -20,6 +20,9 @@ = link_to admin_event_path(@event, format: 'csv'), class: 'btn btn-primary py-3 rounded-0', title: 'CSV for labels' do %i.fas.fa-users %label.text-white Labels + = link_to admin_event_check_in_path(@event), class: 'btn btn-primary py-3' do + %i.fas.fa-qrcode + %label.text-white Check-in .container.py-4.py-lg-5 .row.mb-4 diff --git a/app/views/admin/workshops/show.html.haml b/app/views/admin/workshops/show.html.haml index 6787b756b..de27639e1 100644 --- a/app/views/admin/workshops/show.html.haml +++ b/app/views/admin/workshops/show.html.haml @@ -26,6 +26,9 @@ = link_to admin_workshop_attendees_emails_path(@workshop, format: 'text'), title: 'Attendee emails', class: 'btn btn-primary py-3' do %i.fas.fa-at %label.text-white Emails + = link_to admin_workshop_check_in_path(@workshop), class: 'btn btn-primary py-3' do + %i.fas.fa-qrcode + %label.text-white Check-in = link_to admin_workshop_path(@workshop), method: :delete, data: { confirm: 'Are you sure you want to delete this workshop?' }, title: 'Destroy workshop', class: 'btn btn-primary py-3 rounded-0' do %i.fas.fa-times %label.text-white Destroy From 65941f05cb356b069bd0ad6ed24e4f35a6cf5df6 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:39:01 +0200 Subject: [PATCH 06/26] feat: add public check-in flow with role selection and confirmation --- app/controllers/check_ins_controller.rb | 102 ++++++++++++++++++ app/views/check_ins/confirm.html.haml | 7 ++ app/views/check_ins/new.html.haml | 33 ++++++ spec/controllers/check_ins_controller_spec.rb | 33 ++++++ 4 files changed, 175 insertions(+) create mode 100644 app/controllers/check_ins_controller.rb create mode 100644 app/views/check_ins/confirm.html.haml create mode 100644 app/views/check_ins/new.html.haml create mode 100644 spec/controllers/check_ins_controller_spec.rb diff --git a/app/controllers/check_ins_controller.rb b/app/controllers/check_ins_controller.rb new file mode 100644 index 000000000..9aa05334c --- /dev/null +++ b/app/controllers/check_ins_controller.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +class CheckInsController < ApplicationController + before_action :load_parent + before_action :store_referer_path, only: [:new] + before_action :authenticate_member!, only: [:new] + + def new + @invitation = find_invitation + @suggested_role = infer_role + @already_checked_in = already_checked_in?(@invitation) + end + + def create + role = params[:role] + invitation = find_or_create_invitation(role) + mark_attended(invitation) + redirect_to check_in_confirm_path + end + + def confirm + @invitation = find_invitation + end + + private + + def load_parent + @parent = Event.find_by(check_in_code: params[:code]) || + Workshop.find_by(check_in_code: params[:code]) + raise ActiveRecord::RecordNotFound unless @parent + end + + helper_method :check_in_submit_path, :check_in_confirm_path + + def check_in_submit_path + @parent.is_a?(Event) ? + check_in_e_path(code: @parent.check_in_code) : + check_in_w_path(code: @parent.check_in_code) + end + + def check_in_confirm_path + @parent.is_a?(Event) ? + check_in_e_confirm_path(code: @parent.check_in_code) : + check_in_w_confirm_path(code: @parent.check_in_code) + end + + def store_referer_path + session[:referer_path] = request.path unless logged_in? + end + + def already_checked_in?(invitation) + return false unless invitation + + if @parent.is_a?(Event) + invitation.verified? + else + invitation.attended? + end + end + + def find_invitation + if @parent.is_a?(Event) + Invitation.find_by(event: @parent, member: current_user) + else + WorkshopInvitation.find_by(workshop: @parent, member: current_user) + end + end + + def find_or_create_invitation(role) + if @parent.is_a?(Event) + Invitation.find_or_create_by!(event: @parent, member: current_user, role: role) + else + WorkshopInvitation.find_or_create_by!(workshop: @parent, member: current_user, role: role) + end + end + + def mark_attended(invitation) + attrs = { attending: true, source: "check_in" } + if @parent.is_a?(Event) + attrs[:verified] = true + else + attrs[:attended] = true + end + invitation.update!(attrs) + end + + def infer_role + return @invitation.role if @invitation.present? + + groups = current_user.groups + student = groups.students.any? + coach = groups.coaches.any? + + if student && !coach + "Student" + elsif coach && !student + "Coach" + else + nil + end + end +end diff --git a/app/views/check_ins/confirm.html.haml b/app/views/check_ins/confirm.html.haml new file mode 100644 index 000000000..967e5c5aa --- /dev/null +++ b/app/views/check_ins/confirm.html.haml @@ -0,0 +1,7 @@ +-# app/views/check_ins/confirm.html.haml + +.text-center.py-5 + %h1.text-success ✓ You're checked in! + %p.lead As a #{@invitation.role} at #{@parent.to_s} + %p.text-muted= humanize_date(@parent.date_and_time) + = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-4" diff --git a/app/views/check_ins/new.html.haml b/app/views/check_ins/new.html.haml new file mode 100644 index 000000000..e075e7b93 --- /dev/null +++ b/app/views/check_ins/new.html.haml @@ -0,0 +1,33 @@ +-# app/views/check_ins/new.html.haml + +%h1= @parent.to_s +%p.lead= humanize_date(@parent.date_and_time) + +- if @already_checked_in + .text-center.py-4 + %h2.text-success ✓ Already checked in! + %p.lead As a #{@invitation.role} + = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-3" +- else + %h3 Welcome, #{current_user.name}! + + - if @invitation.present? + %p You were invited as a #{@invitation.role}. Confirm below. + + - if @suggested_role + %p.text-muted We think you're here as a #{@suggested_role}. + + .row.mt-4 + .col-6 + = form_tag check_in_submit_path, method: :post do + = hidden_field_tag :role, "Student" + = submit_tag "I'm a Student", + class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Student"} #{"btn-outline-secondary" unless @suggested_role == "Student"}", + style: "width: 100%; min-height: 80px; font-size: 1.5rem;" + + .col-6 + = form_tag check_in_submit_path, method: :post do + = hidden_field_tag :role, "Coach" + = submit_tag "I'm a Coach", + class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Coach"} #{"btn-outline-secondary" unless @suggested_role == "Coach"}", + style: "width: 100%; min-height: 80px; font-size: 1.5rem;" diff --git a/spec/controllers/check_ins_controller_spec.rb b/spec/controllers/check_ins_controller_spec.rb new file mode 100644 index 000000000..d950d4675 --- /dev/null +++ b/spec/controllers/check_ins_controller_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CheckInsController do + let(:member) { Fabricate(:member) } + let(:event) { Fabricate(:event) } + + describe "GET new" do + it "renders the role selection page when logged in" do + login(member) + get :new, params: { code: event.check_in_code } + expect(response).to be_successful + end + + it "redirects to auth when not logged in" do + get :new, params: { code: event.check_in_code } + expect(response).to redirect_to("/auth/github") + end + end + + describe "POST create" do + before { login(member) } + + it "creates an invitation with source=check_in" do + post :create, params: { code: event.check_in_code, role: "Student" } + invitation = Invitation.last + expect(invitation.source).to eq("check_in") + expect(invitation.attending).to be true + expect(invitation.verified).to be true + end + end +end From 8a0ba63172c37fe431a47f4f809357b53f050c54 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:42:53 +0200 Subject: [PATCH 07/26] feat: set source column in all invitation-creation code paths --- .../admin/invitation_controller.rb | 4 ++-- .../admin/invitations_controller.rb | 5 +++-- app/controllers/events_controller.rb | 4 ++-- app/controllers/workshops_controller.rb | 3 ++- app/services/invitation_manager.rb | 6 +++--- spec/services/invitation_manager_spec.rb | 20 +++++++++---------- .../behaves_like_sending_workshop_emails.rb | 8 ++++---- 7 files changed, 26 insertions(+), 24 deletions(-) diff --git a/app/controllers/admin/invitation_controller.rb b/app/controllers/admin/invitation_controller.rb index 0b9be860c..6a144b6bb 100644 --- a/app/controllers/admin/invitation_controller.rb +++ b/app/controllers/admin/invitation_controller.rb @@ -5,7 +5,7 @@ class Admin::InvitationController < Admin::ApplicationController def update invitation = Invitation.find_by(token: params[:invitation][:id]) - invitation.update(attending: true, verified: true, verified_by: current_user) + invitation.update(attending: true, verified: true, verified_by: current_user, source: "admin") EventInvitationMailer.attending(invitation.event, invitation.member, invitation).deliver_now @@ -17,7 +17,7 @@ def update def verify invitation = Invitation.find_by(token: params[:invitation_id]) - invitation.update(verified: true, verified_by_id: current_user.id) + invitation.update(verified: true, verified_by_id: current_user.id, source: "admin") EventInvitationMailer.attending(invitation.event, invitation.member, invitation).deliver_now diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index c65d06924..24416fa2a 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -38,7 +38,7 @@ def update_attendance(attending:, attended:) end def update_to_attended - @invitation.update(attended: true) + @invitation.update(attended: true, source: "admin") end def update_to_unattended @@ -50,7 +50,8 @@ def update_to_attending attending: true, rsvp_time: Time.zone.now, automated_rsvp: true, - last_overridden_by_id: current_user.id + last_overridden_by_id: current_user.id, + source: "admin" ) { diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index aeecab00a..0cb5484a9 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -44,7 +44,7 @@ def rsvp ticket = Services::Ticket.new(request, params) member = Member.find_by(email: ticket.email) invitation = member.invitations.where(event: @event, role: 'Student').first - invitation ||= Invitation.create(event: @event, member: member, role: 'Student') + invitation ||= Invitation.create(event: @event, member: member, role: 'Student', source: 'email') invitation.update(attending: true) head :ok @@ -63,7 +63,7 @@ def latest_model_updated def find_invitation_and_redirect_to_event(role) set_event - @invitation = Invitation.find_or_create_by(event: @event, member: current_user, role: role) + @invitation = Invitation.find_or_create_by(event: @event, member: current_user, role: role, source: 'email') redirect_to event_invitation_path(@event, @invitation) end diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index afe29a49e..f33752103 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -46,7 +46,8 @@ def find_attending_invitation(workshop, user) def find_or_create_invitation(workshop, user, role) WorkshopInvitation.find_or_create_by(workshop: workshop, member: user, - role: role) + role: role, + source: 'email') end def user_attending_or_waitlisted?(workshop, user) diff --git a/app/services/invitation_manager.rb b/app/services/invitation_manager.rb index baded4b52..61515e83c 100644 --- a/app/services/invitation_manager.rb +++ b/app/services/invitation_manager.rb @@ -117,7 +117,7 @@ def send_workshop_waiting_list_reminders(workshop) def invite_students_to_event(event, chapter) chapter_students(chapter).each do |student| - invitation = Invitation.new(event: event, member: student, role: 'Student') + invitation = Invitation.new(event: event, member: student, role: 'Student', source: 'email') next unless invitation.save EventInvitationMailer.invite_student(event, student, invitation).deliver_now @@ -128,7 +128,7 @@ def invite_students_to_event(event, chapter) def invite_coaches_to_event(event, chapter) chapter_coaches(chapter).each do |coach| - invitation = Invitation.new(event: event, member: coach, role: 'Coach') + invitation = Invitation.new(event: event, member: coach, role: 'Coach', source: 'email') next unless invitation.save EventInvitationMailer.invite_coach(event, coach, invitation).deliver_now @@ -154,7 +154,7 @@ def chapter_coaches(chapter) end def create_invitation(workshop, member, role) - invitation = WorkshopInvitation.find_or_initialize_by(workshop: workshop, member: member, role: role) + invitation = WorkshopInvitation.find_or_initialize_by(workshop: workshop, member: member, role: role, source: 'email') invitation.save! if invitation.new_record? invitation rescue StandardError => e diff --git a/spec/services/invitation_manager_spec.rb b/spec/services/invitation_manager_spec.rb index a192301ac..63309764a 100644 --- a/spec/services/invitation_manager_spec.rb +++ b/spec/services/invitation_manager_spec.rb @@ -27,11 +27,11 @@ it 'can email only students' do event = Fabricate(:event, chapters: [chapter], audience: 'Students') students.each do |student| - expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Student').and_call_original + expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Student', source: 'email').and_call_original end coaches.each do |student| - expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Coach').and_call_original + expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Coach', source: 'email').and_call_original end manager.send_event_emails(event, chapter) @@ -41,11 +41,11 @@ event = Fabricate(:event, chapters: [chapter], audience: 'Coaches') students.each do |student| - expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Student').and_call_original + expect(Invitation).not_to receive(:new).with(event: event, member: student, role: 'Student', source: 'email').and_call_original end coaches.each do |student| - expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Coach').and_call_original + expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Coach', source: 'email').and_call_original end manager.send_event_emails(event, chapter) @@ -55,11 +55,11 @@ event = Fabricate(:event, chapters: [chapter]) students.each do |student| - expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Student').and_call_original + expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Student', source: 'email').and_call_original end coaches.each do |student| - expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Coach').and_call_original + expect(Invitation).to receive(:new).with(event: event, member: student, role: 'Coach', source: 'email').and_call_original end manager.send_event_emails(event, chapter) @@ -73,14 +73,14 @@ expect(Invitation).not_to( receive(:new) - .with(event: event, member: first_student, role: 'Student') + .with(event: event, member: first_student, role: 'Student', source: 'email') .and_call_original ) other_students.each do |other_student| expect(Invitation).to( receive(:new) - .with(event: event, member: other_student, role: 'Student') + .with(event: event, member: other_student, role: 'Student', source: 'email') .and_call_original ) end @@ -96,14 +96,14 @@ expect(Invitation).not_to( receive(:new) - .with(event: event, member: first_coach, role: 'Coach') + .with(event: event, member: first_coach, role: 'Coach', source: 'email') .and_call_original ) other_coaches.each do |other_coach| expect(Invitation).to( receive(:new) - .with(event: event, member: other_coach, role: 'Coach') + .with(event: event, member: other_coach, role: 'Coach', source: 'email') .and_call_original ) end diff --git a/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb b/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb index 33f9fbf62..4661a239a 100644 --- a/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb +++ b/spec/support/shared_examples/behaves_like_sending_workshop_emails.rb @@ -3,7 +3,7 @@ Fabricate(:students, chapter: chapter, members: students) students.each do |student| - expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: student, role: 'Student').and_call_original + expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: student, role: 'Student', source: 'email').and_call_original end expect { @@ -21,7 +21,7 @@ Fabricate(:coaches, chapter: chapter, members: coaches) coaches.each do |coach| - expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original + expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: coach, role: 'Coach', source: 'email').and_call_original end expect { @@ -40,9 +40,9 @@ Fabricate(:coaches, chapter: chapter, members: coaches + [banned_coach]) coaches.each do |coach| - expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: coach, role: 'Coach').and_call_original + expect(WorkshopInvitation).to receive(:find_or_initialize_by).with(workshop: workshop, member: coach, role: 'Coach', source: 'email').and_call_original end - expect(WorkshopInvitation).not_to receive(:find_or_initialize_by).with(workshop: workshop, member: banned_coach, role: 'Coach') + expect(WorkshopInvitation).not_to receive(:find_or_initialize_by).with(workshop: workshop, member: banned_coach, role: 'Coach', source: 'email') manager.send(send_email, workshop, 'coaches') end From 2050f2df1da29806836e1548af044510f5429138 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 10:46:16 +0200 Subject: [PATCH 08/26] fix: update schema.rb with new check_in_code and source columns --- db/schema.rb | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 206576faf..c9c8b6f68 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_21_050948) do +ActiveRecord::Schema[8.1].define(version: 2026_07_03_083051) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -201,6 +201,7 @@ create_table "events", id: :serial, force: :cascade do |t| t.boolean "announce_only" t.string "audience" + t.string "check_in_code" t.text "coach_description" t.string "coach_questionnaire" t.integer "coach_spaces" @@ -228,6 +229,7 @@ t.string "url" t.integer "venue_id" t.boolean "virtual", default: false, null: false + t.index ["check_in_code"], name: "index_events_on_check_in_code", unique: true t.index ["date_and_time"], name: "index_events_on_date_and_time" t.index ["slug"], name: "index_events_on_slug", unique: true t.index ["venue_id"], name: "index_events_on_venue_id" @@ -339,6 +341,7 @@ t.integer "member_id" t.text "note" t.string "role" + t.string "source" t.string "token" t.datetime "updated_at", precision: nil t.boolean "verified" @@ -483,6 +486,17 @@ t.datetime "updated_at", precision: nil end + create_table "solid_cache_entries", force: :cascade do |t| + t.integer "byte_size", null: false + t.datetime "created_at", null: false + t.binary "key", null: false + t.bigint "key_hash", null: false + t.binary "value", null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end + create_table "sponsors", id: :serial, force: :cascade do |t| t.text "accessibility_info" t.string "avatar" @@ -581,6 +595,7 @@ t.datetime "reminded_at", precision: nil t.string "role" t.datetime "rsvp_time", precision: nil + t.string "source" t.string "token" t.text "tutorial" t.datetime "updated_at", precision: nil @@ -605,6 +620,7 @@ create_table "workshops", id: :serial, force: :cascade do |t| t.integer "chapter_id" + t.string "check_in_code" t.integer "coach_spaces", default: 0 t.datetime "created_at", precision: nil t.datetime "date_and_time", precision: nil @@ -621,6 +637,7 @@ t.datetime "updated_at", precision: nil t.boolean "virtual", default: false t.index ["chapter_id"], name: "index_workshops_on_chapter_id" + t.index ["check_in_code"], name: "index_workshops_on_check_in_code", unique: true t.index ["date_and_time"], name: "index_workshops_on_date_and_time" end From f094dd68d163c4a20b7e718aa71b057c81bf65d8 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 11:19:15 +0200 Subject: [PATCH 09/26] fix: add routes.default_url_options for dev route helpers --- config/environments/development.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/environments/development.rb b/config/environments/development.rb index 5da35e3b7..d56bb42ee 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -90,5 +90,7 @@ Bullet.bullet_logger = true Bullet.console = true Bullet.rails_logger = true + + Rails.application.routes.default_url_options = { host: "localhost", port: 3000 } end end From d58e76cbb9083929117252c7539e6243d995d821 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 11:28:14 +0200 Subject: [PATCH 10/26] fix: use AddressPresenter for PDF venue address --- app/services/check_in_pdf.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index 249903c17..036fa26fd 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -19,7 +19,7 @@ def render venue = @parent.venue pdf.text venue.name, size: 14, color: "555555" if venue.respond_to?(:address) && venue.address.present? - pdf.text venue.address.to_s, size: 12, color: "777777" + pdf.text AddressPresenter.new(venue.address).to_s, size: 12, color: "777777" end pdf.move_down 4 end From 9a29df393afa4260faa998911e53c7345b3e5ece Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 11:32:46 +0200 Subject: [PATCH 11/26] fix: show full URL in PDF, remove bounding box that caused wrapping --- app/services/check_in_pdf.rb | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index 036fa26fd..79127b9a2 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -33,15 +33,14 @@ def render png = qrcode.as_png(module_size: 6) qr_width = 160 - pdf.bounding_box([(pdf.bounds.width - qr_width) / 2.0, pdf.cursor], - width: qr_width, height: pdf.cursor - 20) do - pdf.image StringIO.new(png.to_blob), width: qr_width, position: :center - pdf.move_down 4 - pdf.text "Scan to check in", size: 11, align: :center, color: "999999" - pdf.move_down 2 - pdf.text @parent.check_in_url.sub(%r{^https?://}, ""), - size: 10, align: :center, color: "999999" - end + pdf.image StringIO.new(png.to_blob), + width: qr_width, + position: :center + pdf.move_down 4 + pdf.text "Scan to check in", size: 11, align: :center, color: "999999" + pdf.move_down 2 + pdf.text @parent.check_in_url, + size: 10, align: :center, color: "999999" end.render end From 7a9a764d8a5d52de7fdf740bec4b70fa64ef7917 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 11:35:30 +0200 Subject: [PATCH 12/26] fix: add Bootstrap container wrapper to public check-in views --- app/views/check_ins/confirm.html.haml | 11 +++--- app/views/check_ins/new.html.haml | 53 ++++++++++++++------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/app/views/check_ins/confirm.html.haml b/app/views/check_ins/confirm.html.haml index 967e5c5aa..9c87ec0e3 100644 --- a/app/views/check_ins/confirm.html.haml +++ b/app/views/check_ins/confirm.html.haml @@ -1,7 +1,8 @@ -# app/views/check_ins/confirm.html.haml -.text-center.py-5 - %h1.text-success ✓ You're checked in! - %p.lead As a #{@invitation.role} at #{@parent.to_s} - %p.text-muted= humanize_date(@parent.date_and_time) - = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-4" +.container.py-4.py-lg-5 + .text-center.py-5 + %h1.text-success ✓ You're checked in! + %p.lead As a #{@invitation.role} at #{@parent.to_s} + %p.text-muted= humanize_date(@parent.date_and_time) + = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-4" diff --git a/app/views/check_ins/new.html.haml b/app/views/check_ins/new.html.haml index e075e7b93..6e22073b1 100644 --- a/app/views/check_ins/new.html.haml +++ b/app/views/check_ins/new.html.haml @@ -1,33 +1,34 @@ -# app/views/check_ins/new.html.haml -%h1= @parent.to_s -%p.lead= humanize_date(@parent.date_and_time) +.container.py-4.py-lg-5 + %h1= @parent.to_s + %p.lead= humanize_date(@parent.date_and_time) -- if @already_checked_in - .text-center.py-4 - %h2.text-success ✓ Already checked in! - %p.lead As a #{@invitation.role} - = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-3" -- else - %h3 Welcome, #{current_user.name}! + - if @already_checked_in + .text-center.py-4 + %h2.text-success ✓ Already checked in! + %p.lead As a #{@invitation.role} + = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-3" + - else + %h3 Welcome, #{current_user.name}! - - if @invitation.present? - %p You were invited as a #{@invitation.role}. Confirm below. + - if @invitation.present? + %p You were invited as a #{@invitation.role}. Confirm below. - - if @suggested_role - %p.text-muted We think you're here as a #{@suggested_role}. + - if @suggested_role + %p.text-muted We think you're here as a #{@suggested_role}. - .row.mt-4 - .col-6 - = form_tag check_in_submit_path, method: :post do - = hidden_field_tag :role, "Student" - = submit_tag "I'm a Student", - class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Student"} #{"btn-outline-secondary" unless @suggested_role == "Student"}", - style: "width: 100%; min-height: 80px; font-size: 1.5rem;" + .row.mt-4 + .col-6 + = form_tag check_in_submit_path, method: :post do + = hidden_field_tag :role, "Student" + = submit_tag "I'm a Student", + class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Student"} #{"btn-outline-secondary" unless @suggested_role == "Student"}", + style: "width: 100%; min-height: 80px; font-size: 1.5rem;" - .col-6 - = form_tag check_in_submit_path, method: :post do - = hidden_field_tag :role, "Coach" - = submit_tag "I'm a Coach", - class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Coach"} #{"btn-outline-secondary" unless @suggested_role == "Coach"}", - style: "width: 100%; min-height: 80px; font-size: 1.5rem;" + .col-6 + = form_tag check_in_submit_path, method: :post do + = hidden_field_tag :role, "Coach" + = submit_tag "I'm a Coach", + class: "btn btn-lg btn-block #{"btn-primary" if @suggested_role == "Coach"} #{"btn-outline-secondary" unless @suggested_role == "Coach"}", + style: "width: 100%; min-height: 80px; font-size: 1.5rem;" From 75777659df7f4f6867574fcdf3ab175c8ff72c3c Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 11:41:40 +0200 Subject: [PATCH 13/26] fix: add venue and chapter info to check-in page --- app/views/check_ins/new.html.haml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/views/check_ins/new.html.haml b/app/views/check_ins/new.html.haml index 6e22073b1..01a715330 100644 --- a/app/views/check_ins/new.html.haml +++ b/app/views/check_ins/new.html.haml @@ -4,6 +4,14 @@ %h1= @parent.to_s %p.lead= humanize_date(@parent.date_and_time) + - if @parent.respond_to?(:venue) && @parent.venue.present? + %p + %strong= @parent.venue.name + - if @parent.respond_to?(:chapters) && @parent.chapters.any? + %p.text-muted Organized by #{@parent.chapters.map(&:name).to_sentence} + - elsif @parent.respond_to?(:chapter) && @parent.chapter.present? + %p.text-muted Organized by #{@parent.chapter.name} + - if @already_checked_in .text-center.py-4 %h2.text-success ✓ Already checked in! From 4152eb1932c2bedcb0f5b6ecd948b6cdd083b632 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 13:10:33 +0200 Subject: [PATCH 14/26] fix: show full date with year, start and end time on check-in page --- app/views/check_ins/confirm.html.haml | 2 +- app/views/check_ins/new.html.haml | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/check_ins/confirm.html.haml b/app/views/check_ins/confirm.html.haml index 9c87ec0e3..fb3861200 100644 --- a/app/views/check_ins/confirm.html.haml +++ b/app/views/check_ins/confirm.html.haml @@ -4,5 +4,5 @@ .text-center.py-5 %h1.text-success ✓ You're checked in! %p.lead As a #{@invitation.role} at #{@parent.to_s} - %p.text-muted= humanize_date(@parent.date_and_time) + %p.text-muted= @parent.date_and_time.strftime("%A, %B %d, %Y at %H:%M") = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-4" diff --git a/app/views/check_ins/new.html.haml b/app/views/check_ins/new.html.haml index 01a715330..8dd7ae6e1 100644 --- a/app/views/check_ins/new.html.haml +++ b/app/views/check_ins/new.html.haml @@ -2,7 +2,9 @@ .container.py-4.py-lg-5 %h1= @parent.to_s - %p.lead= humanize_date(@parent.date_and_time) + %p.lead= @parent.date_and_time.strftime("%A, %B %d, %Y at %H:%M") + - if @parent.respond_to?(:ends_at) && @parent.ends_at.present? + %p.text-muted until #{@parent.ends_at.strftime("%H:%M")} - if @parent.respond_to?(:venue) && @parent.venue.present? %p From 6688278168e04af736e18fb7f598d97b8f574a9b Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 13:15:39 +0200 Subject: [PATCH 15/26] fix: show time as 'from HH:MM until HH:MM' on one line --- app/views/check_ins/confirm.html.haml | 6 +++++- app/views/check_ins/new.html.haml | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/views/check_ins/confirm.html.haml b/app/views/check_ins/confirm.html.haml index fb3861200..cbca3e36d 100644 --- a/app/views/check_ins/confirm.html.haml +++ b/app/views/check_ins/confirm.html.haml @@ -4,5 +4,9 @@ .text-center.py-5 %h1.text-success ✓ You're checked in! %p.lead As a #{@invitation.role} at #{@parent.to_s} - %p.text-muted= @parent.date_and_time.strftime("%A, %B %d, %Y at %H:%M") + %p.text-muted + - if @parent.respond_to?(:ends_at) && @parent.ends_at.present? + = "#{@parent.date_and_time.strftime('%A, %B %d, %Y from %H:%M')} until #{@parent.ends_at.strftime('%H:%M')}" + - else + = @parent.date_and_time.strftime('%A, %B %d, %Y at %H:%M') = link_to "Back to Dashboard", dashboard_path, class: "btn btn-primary mt-4" diff --git a/app/views/check_ins/new.html.haml b/app/views/check_ins/new.html.haml index 8dd7ae6e1..af36b0a01 100644 --- a/app/views/check_ins/new.html.haml +++ b/app/views/check_ins/new.html.haml @@ -2,9 +2,11 @@ .container.py-4.py-lg-5 %h1= @parent.to_s - %p.lead= @parent.date_and_time.strftime("%A, %B %d, %Y at %H:%M") - - if @parent.respond_to?(:ends_at) && @parent.ends_at.present? - %p.text-muted until #{@parent.ends_at.strftime("%H:%M")} + %p.lead + - if @parent.respond_to?(:ends_at) && @parent.ends_at.present? + = "#{@parent.date_and_time.strftime('%A, %B %d, %Y from %H:%M')} until #{@parent.ends_at.strftime('%H:%M')}" + - else + = @parent.date_and_time.strftime('%A, %B %d, %Y at %H:%M') - if @parent.respond_to?(:venue) && @parent.venue.present? %p From 69944c91f9fd8015aed96faa899987ce00295077 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 21:55:13 +0200 Subject: [PATCH 16/26] feat: add codebar logo to check-in PDF --- app/assets/images/check_in/logo.png | Bin 0 -> 5931 bytes app/services/check_in_pdf.rb | 7 ++++++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 app/assets/images/check_in/logo.png diff --git a/app/assets/images/check_in/logo.png b/app/assets/images/check_in/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ba919ed8640a10319bf15cbdc682cdd8586760d7 GIT binary patch literal 5931 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 U#~=Kh8#nP3 Date: Fri, 3 Jul 2026 21:56:13 +0200 Subject: [PATCH 17/26] fix: enlarge and center codebar logo in PDF --- app/services/check_in_pdf.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index 244fd88a3..b51f2031b 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -9,11 +9,11 @@ def render Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 20) do |pdf| logo_path = Rails.root.join("app/assets/images/check_in/logo.png") if File.exist?(logo_path) - pdf.image logo_path, width: 148 + pdf.image logo_path, width: 250, position: :center else - pdf.text "codebar", size: 16, color: "333333" + pdf.text "codebar", size: 24, style: :bold, align: :center end - pdf.move_down 8 + pdf.move_down 12 pdf.text @parent.to_s, size: 28, style: :bold pdf.move_down 4 From a1724b9e06ed4c3ce157db3dbf23e8156bf2db56 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 21:57:37 +0200 Subject: [PATCH 18/26] fix: enlarge logo to 350pt, increase top margin to 40pt --- app/services/check_in_pdf.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index b51f2031b..e049489f8 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -6,10 +6,10 @@ def initialize(parent) end def render - Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 20) do |pdf| + Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: [40, 20, 20, 20]) do |pdf| logo_path = Rails.root.join("app/assets/images/check_in/logo.png") if File.exist?(logo_path) - pdf.image logo_path, width: 250, position: :center + pdf.image logo_path, width: 350, position: :center else pdf.text "codebar", size: 24, style: :bold, align: :center end From e50b5ddfc4e23bb0ecbd67847ec93fd452a64b04 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 22:01:21 +0200 Subject: [PATCH 19/26] fix: use SVG logo via prawn-svg for crisp vector rendering --- Gemfile | 1 + Gemfile.lock | 7 ++++ app/assets/images/check_in/logo.png | Bin 5931 -> 0 bytes app/assets/images/check_in/logo.svg | 52 ++++++++++++++++++++++++++++ app/services/check_in_pdf.rb | 24 +++++++++---- 5 files changed, 78 insertions(+), 6 deletions(-) delete mode 100644 app/assets/images/check_in/logo.png create mode 100644 app/assets/images/check_in/logo.svg diff --git a/Gemfile b/Gemfile index ba1ef303e..1c095e7ea 100644 --- a/Gemfile +++ b/Gemfile @@ -128,6 +128,7 @@ gem 'rollbar' gem 'scout_apm' gem 'rqrcode' gem 'prawn' +gem 'prawn-svg', '~> 0.35' gem 'carrierwave-aws', '~> 1.6' gem 'sitemap_generator', '~> 7.0' diff --git a/Gemfile.lock b/Gemfile.lock index 0f45568ec..bdfcc3ea0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -369,6 +369,11 @@ GEM prawn (2.4.0) pdf-core (~> 0.9.0) ttfunk (~> 1.7) + prawn-svg (0.40.3) + css_parser (>= 1.17.1, < 4) + matrix (~> 0.4.2) + prawn (>= 0.11.1, < 3) + rexml (>= 3.4.2, < 4) premailer (1.29.0) addressable css_parser (>= 1.19.0) @@ -679,6 +684,7 @@ DEPENDENCIES pg pickadate-rails prawn + prawn-svg (~> 0.35) premailer-rails pry-byebug pry-rails @@ -866,6 +872,7 @@ CHECKSUMS popper_js (2.11.8) sha256=f4b0be717fc0d50bdb3dbbc55788525a9e0e8f640b76c9971fc34ee609eadbd2 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prawn (2.4.0) sha256=82062744f7126c2d77501da253a154271790254dfa8c309b8e52e79bc5de2abd + prawn-svg (0.40.3) sha256=26237d3a7268143b5ffc6c1354ab93d0a4b81366573c749b6316e6f6f7733c0c premailer (1.29.0) sha256=015f30c520701f3d47fd898886a3eaf4e5171efcc5fd239b872efd1ba61d3da0 premailer-rails (1.12.0) sha256=c13815d161b9bc7f7d3d81396b0bb0a61a90fa9bd89931548bf4e537c7710400 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 diff --git a/app/assets/images/check_in/logo.png b/app/assets/images/check_in/logo.png deleted file mode 100644 index ba919ed8640a10319bf15cbdc682cdd8586760d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5931 zcmZ9QWmJ@1*T)eQq+NRi2#WAqgHH9=W0dL<0}+j`Pjh>OSF( zrp-1=+&mav<@8)No!+^6m^;JpURgO=z*rO=%&lP>Fmo#}I2QI250AcI5%NmQb9&R* zH0(WAzYN(N9>3`r`d|VwDOrW41oDkmu@agVNR;W!~F!>*<;+ z>FXKuloTFYP>(NVB`s&;$9~#}h8qNOq>r66QT6ek};tK`fz4TYh6-bQhVuyCch&qI{Oy8 zXzH0IQ8KVO(t1qCWH83>6uu>#?;Qq)i0*2{e#N))R3H$Mtd z2^KpgixIPoaqQ6_L)H0}$j9bVXkMk~jc12H3JkljVCo?mX7H$o3dvU%9nt|zXPn7g zrw${yeMhf9Z#vp@_&(yx6Umf_U(*=={p6WXsyVLJVT8$KfWFauvS;!n=XYpclK@NN z$tUSQAX)>~miEU%{ddjt$x6PB)m>2V%zZtu25+} zyi3Qdso~EY5$Uh8*OoDXe4aloy)6If&7qcS|589EWzP63Hz3!uCfNdxo4>p(3T9cN zO2&StHb3Au>O4Q#2T>^KKrc26K1D-LErt$yDhK#Cs5K;)AI68xG{ab^pFdD;)kC_j zMk3n{-~s6o-k5CWk!%^~d^du6HOQm@uhy%H1;HPc(dWRvNy^Hr##K^7C(bhodR6_U+J5p1UgBW01;H~%Vyyxq#z-!(y|H6>t5Um*fki7=t!_OdxYPfdrVgLnFdyb#YP z$b|k5B{}EC)~&9Z#Hki2I#`sur^ky2{FFl+uEqzC3Q~vhvZ}2ng!Md5d8OAhE2#@{ zWC09c1+hkanrYOUVKrb(Zht~`1Y;yHwuy=gHvT_5WP3UO>f>VqZgF3MN5v8 zR8jaa{5{i(Z)8#YzW$6HoxX2oqMO-$&Gg|#-NX(~_MflyVkA`=Bj0a=_D zC9pJlGZk&I45=}W68)x|sGxDsnpR68EZ}pn8RqV6o*(XP>pWcT)tkTdU|KTo?D2;V ze#ERy_%0rqadSEb9zz&9&{O^QB(6&%nhPT2K4mt77b$FIVsi(R^?hLDRZ!(u+hZXA z2a(5QU2(5M-Hp5igt^zk@=aZLV_VANi+4i2(H3oxa)(Ngo7?K51A}*ZHySW~G1Q4G z%>MP|!E@1YjO{yzBu4NMmwZgy3uhac+0?G=lpASae*f$kg#$T~Nc<(SxT}z`9s*Zg^Wm|P*R9BNMtT^4`x?UNK$!urP_piHf5AvXCmW_vOuI%ucA zI7i3_9aRyRx()0arZS(U^_4B{bHnY;p;LjbLrb*Jbs)VX>k2kPN}a zYCWL4U8(AC6DA1uva%24)-Gj2e#k!U9@5-;V81BIr&rMM?w^G}#p`b67`7hFuc$CTdI~uILwzdfaNTlGQg)JRdjfNj9F4bT?lyV50TH8zj+k=R)7s{FNA{S+J0pViB z;B1=aZL#fzglLUY9Wy<+WLpl9o*OvTV^;Q0+&cQlNN+^KL;A^R@`f#@B8O$-Pw5Pz z0&7VC)|7aq;)%LJbd=+VXnT#-;w~O1{RSks_cE_Qg#T>LU_6II6)u397%QS%GJ=ZnCnLU`n3*ZQnMtuXcY zdME5T|NTmxds%sgZR}+4i89TBTP;KYjb+KnkcRZ1EUl`Pl?`2SM4xDQ&NL^RQziuQ zZOM={me@lElW`x^MFdBa;1fC;YZ7E_t<;5*ZeMlbGmSRvg4x&I-CD(~;d z_Cz{K(tx(WD3@O*)zKX>Q`!^~SG{^WhDlVDRmndjA+wl#)>Rv7-=2WbQ}Gn{EsX8aG&`6&YUgi{E&l zsFtl>)jIhaHZ<^%JHUN%oqK*sPMWr*q6YKT z0{+DOCqNvZhyb*+V3o#Owqlv#WN9q=i5N&_%(G&0k9cvSGGITp&JNjneGp^(@^CKZ zv!{vAY5L_Bb};4|mt^XaJOP0crq{`3X7)V6yZ#joR($^mZ1IZ^Wa=zJM1lJ8ebgJ} z(u&G8k6qe9aK~IG-ceP2*;SbH?D5r}?lB|h{?he5q)Ttp#@P^6uo3!iYCXNd^UZi0 z?>UAN*vK9P?HBc^0b^r6K7AD8a?k5)rY`u2gggr3PP?|DN^qf?wXsiFsG23>h)t2yXEvu?;b z8np@UMaI)5JNc5{wn8cHFx9ArF^n1QrrlXiPwtlebNxn^-On=laH*BM5<~nSk|E1E zrb{hM#gHv27#(T_MkR9W{ z=RuU3)Qa@O<&sae4(Do;q}T6v_Uef8?a*@|%d^gvmt&T7agoT&XWOPVAfI+50K0f%)dxx-ww&u@moak}q`P z#h?yWzIZbF*@_=LPTFkt?F-dpAOm&FcDCuVq9?}7j&L>Ilorm$aOXJffkz>G2d^{N zYH31oSN1_%~q&BKXEY#_UW6aHvQ^qn;V(8 z0Pa2hmh+=ff}ZJ}WLTUaBDx1nlqBt}{bU?wwwl1>s?brx97c#b5zAt4@sP7wXY%t=LjFPKu# zj^^c(7B7NxloN^^3#55VpJ;6%X*8(!o{_|#YH6mtL4s+0Tr~W^_vUKV36<0B8X%xbQg4pBhQ4CQI{VeH^Fh$6ZMpayIWRzyHiVvw@ zB!*{^(c~#wTl~XIbZ;M##*r`dlOUmuo5u@kE2}T&DHs2WDp5wzPsJ})oY*>>tfdas zOLKsQ;M%GrD#xyie-JoJ>iqfhJ0D7uk>I)YjT{zRA8(&u@x||aMlYar1P9HsHeXWp#MbG1De(n&89Ph3j z|Gb!pm5`edU+Xh+`8umK!`}3|nXo)In)+=CgS{AO8J8$twW)WmB&Wre9mb2j!yx&+ zQ9&Jf`&Tl8-NY$a73m9|k5N9Nu&LU=>{y8k-|g9jD^b3dpS=6gDy(SYstCn5;o27a zU!HrJ*mmgH#45T~7_*J4Kt&Gq)iXaa!j#$eBKJx!&2p8yS}=;sPa`}8CJ(D)zgt9> zK`&aUyr!%K0H=e#8`dj>O^5GeN4k`y&es`QI+R#<=^K@-9~l@Qz7g7f;^R8W@s~H0Om!-yLMAnv@M$=6P4)RLu1P zXX?Wd{|L$HBgPH4<6PzowUu+`ONE2QfB>q*1$GQ5-N{-#HVmU|^eRu{AdapAe>+i* zYtf`zmxKQ?hPP*`IIu&xM1IX7qe4K=jf>UT=m_zeCWdA)ru+N1tc(51Gg&Cuimq0F zR}6(Zo{O)8E^9eW@ut!ukfctV`J&V9ndd8#uW%(Rb(|9xw9RJBrwc@zF(I5$@d`#V zsveT_JQ_5exlW^fPLJI6yI%K_Ddu4s)%K=Cj6e0Q&=nH8O>L2uv`fx_)oqk&J~P0K zrBhyqE+5RgjC0b85LZs<_Rur-&X$RxMj6A(`3 zGX5TPa_uC2TK|>5o5WHhzK8-hkPWTW22{TI0Q7L<4$93rLnTq>*5q~!)o4hRmO`m(lm@c zsv;U}a`8RIr7?_bAFVDw`P0~Pj#VS`-&c#l_2sM|_5VTX?!~?E=S~`@UvqtR!+2&r zL7YxXRCQaCoh(OMVsSm33UaD@HT@Dr7r@3AV|czVDJui$X#6eHSY7Rv28dXm z{qmeP20x7O-1*g2s%co7$>+a|PPW?}J*OvTo3qe0q+mAub4!dI`HTm6KDrA=<&z@yJ_MheRzjsXvNMJ2F`R`w@@vKBN(ye%fND?B47YZG}0gY~cQ0mR;bVr0chpnV}L9$&2`W-Q3eQ3S(SlanDe)z32^TEr18$R ze|oBTQpbO+_dV$V#E~P@+*>Y|oSIqo)UnCFEIq%ie7R9}21U?MCw+~XXU~rMO)of) z{x%0tXF4ecP#`O~qgtC*FMECSrb9covdgg%h!l1(W)4TS*+l9;eLa4x7 zWY}kgHW_@^td?#u9rI_}6{HzjU*2Te;6FupixIz5Bds~$1Qnq$Y?SfM>G4RAH-G<{ zbxpMRxBO5Yh>WoAwx(f_vAK7X3|;;W#*o~M0ne=e&cD3zzYy|2l=A;W$bTuNRU1>o z{d$p|Fso~|nZMAXa80RgnfKC@U)l!pnje$?Z)s9xpB&)( z0-z_gKT;dW@b6oR+lB;7YQ3?foIANZOLuMyEpi@sHs;)KhGI!#N|OIv-+O>Z=w1Kr U#~=Kh8#nP3 + + + + + + + + + + + + + + + + + + diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index e049489f8..3ee077436 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -7,12 +7,7 @@ def initialize(parent) def render Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: [40, 20, 20, 20]) do |pdf| - logo_path = Rails.root.join("app/assets/images/check_in/logo.png") - if File.exist?(logo_path) - pdf.image logo_path, width: 350, position: :center - else - pdf.text "codebar", size: 24, style: :bold, align: :center - end + draw_logo(pdf) pdf.move_down 12 pdf.text @parent.to_s, size: 28, style: :bold @@ -51,6 +46,23 @@ def render private + def draw_logo(pdf) + svg_path = Rails.root.join("app/assets/images/check_in/logo.svg") + + if File.exist?(svg_path) + svg_content = File.read(svg_path) + mark_size = 60 + label_size = 28 + + pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width, height: mark_size + 4) do + pdf.svg svg_content, width: mark_size, at: [0, mark_size], enable_web_requests: false + pdf.draw_text "codebar", at: [mark_size + 10, mark_size - 16], size: label_size + end + else + pdf.text "codebar", size: 24, style: :bold, align: :center + end + end + def formatted_date dt = @parent.date_and_time dt.strftime("%A, %B %d, %Y at %H:%M") From 14c135a919dd497e5b62576ca468fc11403ca33d Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 22:03:25 +0200 Subject: [PATCH 20/26] fix: add initializer to include prawn-svg extension in Prawn::Document --- config/initializers/prawn_svg.rb | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 config/initializers/prawn_svg.rb diff --git a/config/initializers/prawn_svg.rb b/config/initializers/prawn_svg.rb new file mode 100644 index 000000000..d03edc56f --- /dev/null +++ b/config/initializers/prawn_svg.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.config.after_initialize do + Prawn::Document.include(Prawn::SVG::Extension) +end From f4db6b502632a55d11459743b6cb7bff17406ffa Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 22:05:22 +0200 Subject: [PATCH 21/26] fix: double logo size and center horizontally --- app/services/check_in_pdf.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index 3ee077436..d2b59def7 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -51,12 +51,18 @@ def draw_logo(pdf) if File.exist?(svg_path) svg_content = File.read(svg_path) - mark_size = 60 - label_size = 28 + mark_size = 120 + label_size = 48 + spacing = 16 + text_width = pdf.width_of("codebar", size: label_size) + total_width = mark_size + spacing + text_width + x_start = (pdf.bounds.width - total_width) / 2.0 - pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width, height: mark_size + 4) do - pdf.svg svg_content, width: mark_size, at: [0, mark_size], enable_web_requests: false - pdf.draw_text "codebar", at: [mark_size + 10, mark_size - 16], size: label_size + pdf.bounding_box([x_start, pdf.cursor], width: total_width, height: mark_size + 4) do + mark_y = mark_size + pdf.svg svg_content, width: mark_size, at: [0, mark_y], enable_web_requests: false + text_y = mark_y - (mark_size - label_size) / 2.0 - label_size * 0.25 + pdf.draw_text "codebar", at: [mark_size + spacing, text_y], size: label_size end else pdf.text "codebar", size: 24, style: :bold, align: :center From f186ac932035a400e64f080784588e1355ecdd1d Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 22:07:10 +0200 Subject: [PATCH 22/26] fix: remove text, center SVG mark only --- app/services/check_in_pdf.rb | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index d2b59def7..316342ca5 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -52,18 +52,10 @@ def draw_logo(pdf) if File.exist?(svg_path) svg_content = File.read(svg_path) mark_size = 120 - label_size = 48 - spacing = 16 - text_width = pdf.width_of("codebar", size: label_size) - total_width = mark_size + spacing + text_width - x_start = (pdf.bounds.width - total_width) / 2.0 - - pdf.bounding_box([x_start, pdf.cursor], width: total_width, height: mark_size + 4) do - mark_y = mark_size - pdf.svg svg_content, width: mark_size, at: [0, mark_y], enable_web_requests: false - text_y = mark_y - (mark_size - label_size) / 2.0 - label_size * 0.25 - pdf.draw_text "codebar", at: [mark_size + spacing, text_y], size: label_size - end + x_start = (pdf.bounds.width - mark_size) / 2.0 + bottom_y = pdf.cursor - mark_size + pdf.svg svg_content, width: mark_size, at: [x_start, bottom_y], enable_web_requests: false + pdf.move_down mark_size + 4 else pdf.text "codebar", size: 24, style: :bold, align: :center end From d49600f3b36e49e4d3e06ec4ef52c7720f5983a2 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 22:13:02 +0200 Subject: [PATCH 23/26] fix: use bounding box for SVG mark to prevent overflow to second page --- app/services/check_in_pdf.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index 316342ca5..5968d5c79 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -6,9 +6,9 @@ def initialize(parent) end def render - Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: [40, 20, 20, 20]) do |pdf| + Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 10) do |pdf| draw_logo(pdf) - pdf.move_down 12 + pdf.move_down 24 pdf.text @parent.to_s, size: 28, style: :bold pdf.move_down 4 @@ -53,9 +53,9 @@ def draw_logo(pdf) svg_content = File.read(svg_path) mark_size = 120 x_start = (pdf.bounds.width - mark_size) / 2.0 - bottom_y = pdf.cursor - mark_size - pdf.svg svg_content, width: mark_size, at: [x_start, bottom_y], enable_web_requests: false - pdf.move_down mark_size + 4 + pdf.bounding_box([x_start, pdf.cursor], width: mark_size, height: mark_size) do + pdf.svg svg_content, width: mark_size, at: [0, 0], enable_web_requests: false + end else pdf.text "codebar", size: 24, style: :bold, align: :center end From b621b9d7670522b210ec1fc77e23e4d6ffbc1337 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 22:14:40 +0200 Subject: [PATCH 24/26] fix: double mark to 240pt, remove top margin --- app/services/check_in_pdf.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index 5968d5c79..c43ba70bd 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -6,7 +6,7 @@ def initialize(parent) end def render - Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 10) do |pdf| + Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 0) do |pdf| draw_logo(pdf) pdf.move_down 24 @@ -51,7 +51,7 @@ def draw_logo(pdf) if File.exist?(svg_path) svg_content = File.read(svg_path) - mark_size = 120 + mark_size = 240 x_start = (pdf.bounds.width - mark_size) / 2.0 pdf.bounding_box([x_start, pdf.cursor], width: mark_size, height: mark_size) do pdf.svg svg_content, width: mark_size, at: [0, 0], enable_web_requests: false From 70343ed58b19d9441b2f20bde5f4c791b4a81ccb Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 22:16:19 +0200 Subject: [PATCH 25/26] revert: restore 120pt mark and 10pt margin --- app/services/check_in_pdf.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/check_in_pdf.rb b/app/services/check_in_pdf.rb index c43ba70bd..5968d5c79 100644 --- a/app/services/check_in_pdf.rb +++ b/app/services/check_in_pdf.rb @@ -6,7 +6,7 @@ def initialize(parent) end def render - Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 0) do |pdf| + Prawn::Document.new(page_layout: :landscape, page_size: "A4", margin: 10) do |pdf| draw_logo(pdf) pdf.move_down 24 @@ -51,7 +51,7 @@ def draw_logo(pdf) if File.exist?(svg_path) svg_content = File.read(svg_path) - mark_size = 240 + mark_size = 120 x_start = (pdf.bounds.width - mark_size) / 2.0 pdf.bounding_box([x_start, pdf.cursor], width: mark_size, height: mark_size) do pdf.svg svg_content, width: mark_size, at: [0, 0], enable_web_requests: false From 10c415b39914e12f7d51afe0fef8401cfd9e5a7f Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Mon, 6 Jul 2026 09:44:16 +0200 Subject: [PATCH 26/26] fix: add container wrapper to admin check-in view --- app/views/admin/check_ins/show.html.haml | 51 ++++++++++++------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/app/views/admin/check_ins/show.html.haml b/app/views/admin/check_ins/show.html.haml index e38708bbd..5acd952b5 100644 --- a/app/views/admin/check_ins/show.html.haml +++ b/app/views/admin/check_ins/show.html.haml @@ -1,31 +1,32 @@ -# app/views/admin/check_ins/show.html.haml -%h2 Check-in for #{@parent.to_s} +.container.py-4.py-lg-5 + %h2 Check-in for #{@parent.to_s} -.alert.alert-info - %p.mb-0 - Let attendees mark themselves as attended by scanning the QR code on the PDF. - Once scanned, they sign in with GitHub and confirm their role. - You'll see their attendance update in the event admin page. + .alert.alert-info + %p.mb-0 + Let attendees mark themselves as attended by scanning the QR code on the PDF. + Once scanned, they sign in with GitHub and confirm their role. + You'll see their attendance update in the event admin page. -.card.mb-4 - .card-body - %h5.card-title Check-in URL - %p.lead.mb-1= @parent.check_in_code - %p.text-muted.small= @parent.check_in_url - %p.text-muted.small Share this URL with anyone who can't scan the QR code. + .card.mb-4 + .card-body + %h5.card-title Check-in URL + %p.lead.mb-1= @parent.check_in_code + %p.text-muted.small= @parent.check_in_url + %p.text-muted.small Share this URL with anyone who can't scan the QR code. -.card.mb-4 - .card-body - %h5.card-title How to use - %ol - %li Download the PDF below. - %li Print it or display it on a screen at the venue entrance. - %li Attendees scan the QR code with their phone. - %li They sign in with GitHub and select their role (Student/Coach). - %li They're checked in! You can see attendance on the event/workshop admin page. + .card.mb-4 + .card-body + %h5.card-title How to use + %ol + %li Download the PDF below. + %li Print it or display it on a screen at the venue entrance. + %li Attendees scan the QR code with their phone. + %li They sign in with GitHub and select their role (Student/Coach). + %li They're checked in! You can see attendance on the event/workshop admin page. -= link_to url_for([:admin, @parent, :check_in, format: :pdf]), - class: 'btn btn-primary btn-lg' do - %i.fas.fa-download - Download PDF + = link_to url_for([:admin, @parent, :check_in, format: :pdf]), + class: 'btn btn-primary btn-lg' do + %i.fas.fa-download + Download PDF