diff --git a/.github/scripts/auto_extract.py b/.github/scripts/auto_extract.py
index 9ce1930..14d9855 100644
--- a/.github/scripts/auto_extract.py
+++ b/.github/scripts/auto_extract.py
@@ -261,57 +261,11 @@ def extract_with_openai(page_content, additional_notes=""):
util.fail(f"OpenAI API error: {str(e)}")
-def parse_issue_body(body):
- """Parse the issue body to get URL and notes."""
- lines = body.strip().split("\n")
- data = {}
-
- current_field = None
- current_value = []
-
- for line in lines:
- if line.startswith("### "):
- if current_field and current_value:
- data[current_field] = "\n".join(current_value).strip()
- # Convert "Link to Hackathon" -> "link_to_hackathon"
- current_field = line[4:].strip().lower().replace(" ", "_").replace("?", "").replace("(", "").replace(")", "")
- current_value = []
- elif current_field:
- if line.strip() and line.strip() != "_No response_":
- current_value.append(line)
-
- if current_field and current_value:
- data[current_field] = "\n".join(current_value).strip()
-
- return data
-
-
-def parse_state(data):
- """Map optional issue status field to listing state."""
- status = (data.get("status", "") or "").strip().lower()
- if "soon" in status:
- return "opens_soon"
- if "open" in status:
- return "open"
- # Sensible default for auto-extracted listings.
- return "open"
-
-
-def parse_deadline(data):
- """Parse optional deadline and normalize to ISO YYYY-MM-DD."""
- raw = (data.get("deadline_optional", "") or data.get("deadline", "")).strip()
- if not raw:
- return None
- try:
- return util.parse_deadline_date(raw).isoformat()
- except ValueError:
- util.fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.")
-
-
def extract_url_from_body(body):
"""Try multiple methods to extract URL from issue body."""
- # Method 1: Parse structured fields
- data = parse_issue_body(body)
+ # Method 1: Parse structured fields (strip_symbols matches the field keys
+ # this script looks up, e.g. deadline_optional).
+ data = util.parse_issue_body(body, strip_symbols=True)
# Try various field names
url_fields = [
@@ -365,8 +319,8 @@ def main():
print(f"Extracted URL: {url}")
notes = data.get("any_additional_context_optional", "") or data.get("notes", "")
- state = parse_state(data)
- deadline = parse_deadline(data)
+ state = util.parse_state(data)
+ deadline = util.parse_deadline(data, "deadline_optional", "deadline")
print(f"Fetching content from: {url}")
diff --git a/.github/scripts/contribution_approved.py b/.github/scripts/contribution_approved.py
index 051e236..bd25039 100644
--- a/.github/scripts/contribution_approved.py
+++ b/.github/scripts/contribution_approved.py
@@ -26,64 +26,6 @@ def get_first(data, *keys):
return ""
-def parse_state(data):
- """Map issue fields to listing state."""
- status = get_first(data, "status")
- status_lc = status.lower()
- if "soon" in status_lc:
- return "opens_soon"
- if "open" in status_lc:
- return "open"
-
- # Backward compatibility with older template field.
- active_str = get_first(
- data,
- "is_this_hackathon_currently_open_for_registration?",
- "is_this_hackathon_currently_open_for_registration",
- ).lower()
- if active_str == "no":
- return "opens_soon"
- return "open"
-
-
-def parse_deadline(data):
- """Parse optional deadline and normalize to ISO YYYY-MM-DD."""
- raw = get_first(data, "deadline", "deadline_(optional)")
- if not raw:
- return None
- try:
- return util.parse_deadline_date(raw).isoformat()
- except ValueError:
- util.fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.")
-
-
-def parse_issue_body(body, labels):
- """Parse the issue body based on issue type."""
- lines = body.strip().split("\n")
- data = {}
-
- current_field = None
- current_value = []
-
- for line in lines:
- # Check for field headers (### Field Name)
- if line.startswith("### "):
- if current_field and current_value:
- data[current_field] = "\n".join(current_value).strip()
- current_field = line[4:].strip().lower().replace(" ", "_")
- current_value = []
- elif current_field:
- # Skip "_No response_" placeholders
- if line.strip() != "_No response_":
- current_value.append(line)
-
- # Don't forget the last field
- if current_field and current_value:
- data[current_field] = "\n".join(current_value).strip()
-
- return data
-
-
def handle_new_opportunity(data, username, is_quick_add=False):
"""Handle adding a new hackathon."""
listings = util.get_listings_from_json()
@@ -125,8 +67,8 @@ def handle_new_opportunity(data, username, is_quick_add=False):
# Get prize (optional)
prize = get_first(data, "prize_pool_(optional)", "prize") or "โ"
# Map user-submitted status/deadline fields into canonical listing fields.
- state = parse_state(data)
- deadline = parse_deadline(data)
+ state = util.parse_state(data)
+ deadline = util.parse_deadline(data, "deadline", "deadline_(optional)")
# Create new listing
new_listing = {
@@ -223,7 +165,7 @@ def main():
username = issue.get("user", {}).get("login", "unknown")
# Parse the issue body
- data = parse_issue_body(body, labels)
+ data = util.parse_issue_body(body)
# Check if this is a quick add
is_quick_add = "quick_add" in labels
diff --git a/.github/scripts/generate_gallery.py b/.github/scripts/generate_gallery.py
index f812618..111ae83 100644
--- a/.github/scripts/generate_gallery.py
+++ b/.github/scripts/generate_gallery.py
@@ -51,12 +51,20 @@ def esc(text):
def tile(photo):
image = esc(photo["image"])
- hackathon = esc(photo.get("hackathon", ""))
+ # Surface caption + credit so contributor attribution isn't dropped. In a
+ # justified image collage there's no room for a visible caption line, so the
+ # metadata rides along in the alt/title (hover tooltip + accessibility).
+ parts = [p for p in (photo.get("hackathon", ""), photo.get("caption", "")) if p]
+ credit = photo.get("credit", "")
+ credit_url = photo.get("credit_url", "")
+ if credit:
+ parts.append(f"Photo: {credit}" + (f" ({credit_url})" if credit_url else ""))
+ label = esc(" ยท ".join(parts)) if parts else esc(photo.get("hackathon", ""))
# Fixed height + no width keeps each photo's aspect ratio, so they tile
# together like a collage instead of sitting in separate boxes.
return (
f''
- f'
'
+ f'
'
)
diff --git a/.github/scripts/test_scripts.py b/.github/scripts/test_scripts.py
index 976b767..ac6ca1c 100644
--- a/.github/scripts/test_scripts.py
+++ b/.github/scripts/test_scripts.py
@@ -8,7 +8,6 @@
from datetime import date
import util
-import contribution_approved as ca
import auto_extract as ax
@@ -55,24 +54,30 @@ def test_parses_field_sections(self):
"### Hackathon Name\nHackMIT 2026\n\n"
"### Link to Hackathon Page\nhttps://hackmit.org\n"
)
- data = ca.parse_issue_body(body, [])
+ data = util.parse_issue_body(body)
self.assertEqual(data["hackathon_name"], "HackMIT 2026")
self.assertEqual(data["link_to_hackathon_page"], "https://hackmit.org")
def test_skips_no_response_placeholder(self):
- data = ca.parse_issue_body("### Prize Pool (optional)\n_No response_\n", [])
+ data = util.parse_issue_body("### Prize Pool (optional)\n_No response_\n")
self.assertFalse(data.get("prize_pool_(optional)"))
+ def test_strip_symbols_normalizes_keys(self):
+ data = util.parse_issue_body("### Deadline (optional)\n2026-07-04\n", strip_symbols=True)
+ self.assertEqual(data.get("deadline_optional"), "2026-07-04")
+
class ParseStateAndDeadline(unittest.TestCase):
def test_state(self):
- self.assertEqual(ca.parse_state({"status": "Opens soon"}), "opens_soon")
- self.assertEqual(ca.parse_state({"status": "Open now"}), "open")
- self.assertEqual(ca.parse_state({}), "open")
+ self.assertEqual(util.parse_state({"status": "Opens soon"}), "opens_soon")
+ self.assertEqual(util.parse_state({"status": "Open now"}), "open")
+ self.assertEqual(util.parse_state({}), "open")
def test_deadline(self):
- self.assertEqual(ca.parse_deadline({"deadline": "2026-07-04"}), "2026-07-04")
- self.assertIsNone(ca.parse_deadline({}))
+ self.assertEqual(
+ util.parse_deadline({"deadline": "2026-07-04"}, "deadline"), "2026-07-04"
+ )
+ self.assertIsNone(util.parse_deadline({}, "deadline"))
class SsrfGuard(unittest.TestCase):
diff --git a/.github/scripts/util.py b/.github/scripts/util.py
index 3518177..e4186cf 100644
--- a/.github/scripts/util.py
+++ b/.github/scripts/util.py
@@ -258,6 +258,80 @@ def generate_uuid():
return str(uuid.uuid4())
+DEFAULT_REPO = "Jose-Gael-Cruz-Lopez/hackhq"
+
+
+def repo_slug():
+ """owner/repo โ from GITHUB_REPOSITORY (set by Actions) or the default."""
+ return os.environ.get("GITHUB_REPOSITORY") or DEFAULT_REPO
+
+
+def repo_url():
+ """Base GitHub URL for this repository."""
+ return f"https://github.com/{repo_slug()}"
+
+
+def parse_issue_body(body, strip_symbols=False):
+ """Parse a GitHub issue body's '### Field' sections into a {field: value} dict.
+
+ Field names are lowercased with spaces -> underscores. Set strip_symbols=True
+ to also drop ? ( ) from keys (used where field lookups omit those chars).
+ Shared by contribution_approved.py and auto_extract.py.
+ """
+ data = {}
+ current_field = None
+ current_value = []
+ for line in body.strip().split("\n"):
+ if line.startswith("### "):
+ if current_field and current_value:
+ data[current_field] = "\n".join(current_value).strip()
+ name = line[4:].strip().lower().replace(" ", "_")
+ if strip_symbols:
+ name = name.replace("?", "").replace("(", "").replace(")", "")
+ current_field = name
+ current_value = []
+ elif current_field:
+ if line.strip() and line.strip() != "_No response_":
+ current_value.append(line)
+ if current_field and current_value:
+ data[current_field] = "\n".join(current_value).strip()
+ return data
+
+
+def parse_state(data):
+ """Map issue status fields to a listing state (open / opens_soon)."""
+ status = str(data.get("status") or "").strip().lower()
+ if "soon" in status:
+ return "opens_soon"
+ if "open" in status:
+ return "open"
+ # Backward compatibility with the older registration-open field.
+ active = str(
+ data.get("is_this_hackathon_currently_open_for_registration?")
+ or data.get("is_this_hackathon_currently_open_for_registration")
+ or ""
+ ).strip().lower()
+ if active == "no":
+ return "opens_soon"
+ return "open"
+
+
+def parse_deadline(data, *keys):
+ """Return an ISO deadline from the first present key, or None (fail on bad format)."""
+ raw = ""
+ for key in keys:
+ value = data.get(key)
+ if value and str(value).strip():
+ raw = str(value).strip()
+ break
+ if not raw:
+ return None
+ try:
+ return parse_deadline_date(raw).isoformat()
+ except ValueError:
+ fail("Invalid deadline format. Please use YYYY-MM-DD or MM/DD/YYYY.")
+
+
def clean_url(url):
"""Clean and normalize a URL."""
url = url.strip()
diff --git a/.github/scripts/weekly_digest.py b/.github/scripts/weekly_digest.py
index 95125bf..876349f 100644
--- a/.github/scripts/weekly_digest.py
+++ b/.github/scripts/weekly_digest.py
@@ -15,6 +15,8 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
+import util
+
PST = ZoneInfo("America/Los_Angeles")
README = os.path.join(os.path.dirname(__file__), "..", "..", "README.md")
DIGEST = os.path.join(os.path.dirname(__file__), "..", "..", "digest.md")
@@ -123,7 +125,7 @@ def main():
out = []
out.append(f"# ๐ฌ Weekly Digest โ {today.strftime('%B %d, %Y')}\n")
out.append(
- f"_Auto-generated. Source: [README.md](https://github.com/Jose-Gael-Cruz-Lopez/hackhq)._\n"
+ f"_Auto-generated. Source: [README.md]({util.repo_url()})._\n"
)
if closing_rows:
@@ -142,7 +144,7 @@ def main():
out.append("\nNo new entries or closing-soon flags this week. Stay tuned ๐ฑ\n")
out.append(
- f"\n---\n_Want to contribute? [Open an issue](https://github.com/Jose-Gael-Cruz-Lopez/hackhq/issues/new/choose)._"
+ f"\n---\n_Want to contribute? [Open an issue]({util.repo_url()}/issues/new/choose)._"
)
with open(DIGEST, "w") as f:
diff --git a/georgetown b/georgetown
deleted file mode 100644
index ff97e6d..0000000
--- a/georgetown
+++ /dev/null
@@ -1 +0,0 @@
-| โ
[OPEN] | Georgetown University | Hoya Hacks 2027 โ Jan 22โ24, 2027 | In-Person | Washington, DC | 1 non-cash prize |
| Jun 27, 2026 |
diff --git a/web/components/hq/deck.tsx b/web/components/hq/deck.tsx
index cc6957d..622c01d 100644
--- a/web/components/hq/deck.tsx
+++ b/web/components/hq/deck.tsx
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import type { Hackathon, HackState } from "@/lib/types-hq";
import { STATE_META, countdown } from "@/lib/types-hq";
-import { useHQ } from "./store";
+import { useSelection, useTracker } from "./store";
type StatusFilter = "all" | HackState;
type FormatFilter = "all" | "In-Person" | "Virtual";
@@ -169,7 +169,7 @@ function FilterPill({
}
function SaveHeart({ h, dark }: { h: Hackathon; dark?: boolean }) {
- const { isTracked, save, remove } = useHQ();
+ const { isTracked, save, remove } = useTracker();
const tracked = isTracked(h.id);
return (