Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ JOB_RETRY_MAX_SECONDS=300
JOB_TIMEOUT_SECONDS=600
JOB_RESULT_TTL_SECONDS=3600
GIG_RECRUITING_STALE_DAYS=7
GIG_RECRUITING_REMINDER_MAX_AGE_DAYS=90

# Internal transfer storage (optional defaults for host-run app services)
MINIO_ENDPOINT=http://127.0.0.1:9000
Expand Down
5 changes: 5 additions & 0 deletions apps/admin_dashboard/src/dashboard-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"
import {
daysSince,
displayOnboarder,
formatDate,
githubUrl,
labelForOnboardingState,
linkedinUrl,
Expand Down Expand Up @@ -45,4 +46,8 @@ describe("dashboard utility helpers", () => {
expect(daysSince("2026-05-17T00:00:00Z", now)).toBe(0)
expect(daysSince("not a date", now)).toBeNull()
})

it("includes the year in formatted timestamps", () => {
expect(formatDate("2026-01-27T02:26:00Z")).toContain("2026")
})
})
1 change: 1 addition & 0 deletions apps/admin_dashboard/src/dashboard-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function formatDate(value?: string | null) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
Expand Down
24 changes: 21 additions & 3 deletions apps/admin_dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ function App() {
const [status, setStatus] = useState("")
const [jobType, setJobType] = useState("")
const [gigStatus, setGigStatus] = useState("")
const [gigIncludeHistorical, setGigIncludeHistorical] = useState(false)
const [gigLimit, setGigLimit] = useState(100)
const [projectQuery, setProjectQuery] = useState("")
const [projectStatus, setProjectStatus] = useState(initialProjectDetailId ? "" : "Open")
Expand Down Expand Up @@ -934,6 +935,7 @@ function App() {
function gigsUrl() {
const params = new URLSearchParams({ limit: String(gigLimit) })
if (gigStatus) params.set("status", gigStatus)
if (gigIncludeHistorical) params.set("include_historical", "true")
return `/dashboard/api/gigs?${params.toString()}`
}

Expand Down Expand Up @@ -1793,10 +1795,10 @@ function App() {
if (view === "jobs" && permissions.length > 0) void loadJobs()
}, [minutes, status])

// biome-ignore lint/correctness/useExhaustiveDependencies: gigs reload intentionally follows status filter changes only while gigs is active.
// biome-ignore lint/correctness/useExhaustiveDependencies: gigs reload intentionally follows list filter changes only while gigs is active.
useEffect(() => {
if (view === "gigs" && permissions.length > 0) void loadGigs()
}, [gigStatus, gigLimit])
}, [gigStatus, gigIncludeHistorical, gigLimit])

// biome-ignore lint/correctness/useExhaustiveDependencies: projects reload intentionally follows status changes only while projects is active.
useEffect(() => {
Expand Down Expand Up @@ -2094,12 +2096,15 @@ function App() {
sort={sort.gigs}
loading={loading}
status={gigStatus}
includeHistorical={gigIncludeHistorical}
limit={gigLimit}
staleDays={staleRecruitingDays}
canWrite={can("gigs:write")}
canIncludeHistorical={can("people:read")}
crmContactUrl={crmContactUrl}
crmAttachmentUrl={crmAttachmentUrl}
setStatus={setGigStatus}
setIncludeHistorical={setGigIncludeHistorical}
setLimit={setGigLimit}
onRefresh={refreshGigsView}
onSort={(key) => handleSort("gigs", key)}
Expand Down Expand Up @@ -4417,12 +4422,15 @@ function GigsView(props: {
sort: { key: string; direction: SortDirection }
loading: Record<string, boolean>
status: string
includeHistorical: boolean
limit: number
staleDays: number
canWrite: boolean
canIncludeHistorical: boolean
crmContactUrl: (contactId?: string) => string
crmAttachmentUrl: (attachmentId?: string) => string
setStatus: (value: string) => void
setIncludeHistorical: (value: boolean) => void
setLimit: (value: number) => void
onRefresh: () => void
onSort: (key: string) => void
Expand All @@ -4442,7 +4450,7 @@ function GigsView(props: {
{ total: 0, applications: 0, interested: 0, stale: 0 },
)
const filterBar = (
<Card className="grid gap-3 p-4 md:grid-cols-[minmax(160px,1fr)_auto_auto] md:items-end">
<Card className="grid gap-3 p-4 md:grid-cols-[minmax(160px,1fr)_auto_auto_auto] md:items-end">
<Label>
Status
<Select
Expand All @@ -4458,6 +4466,16 @@ function GigsView(props: {
))}
</Select>
</Label>
{props.canIncludeHistorical ? (
<label className="flex min-h-9 items-center gap-2 text-xs font-bold text-muted-foreground">
<input
type="checkbox"
checked={props.includeHistorical}
onChange={(event) => props.setIncludeHistorical(event.target.checked)}
/>
Include historical
</label>
) : null}
<Button
id="refreshGigs"
type="button"
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/five08/backend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2797,6 +2797,7 @@ async def dashboard_people_handler(
async def dashboard_gigs_handler(
request: Request,
status: str | None = Query(default=None),
include_historical: bool = Query(default=False),
limit: int = Query(default=100, ge=1, le=500),
) -> JSONResponse:
"""Return dashboard-visible Discord gigs and candidate fit snapshots."""
Expand Down Expand Up @@ -2826,6 +2827,7 @@ async def dashboard_gigs_handler(
settings,
viewer_discord_user_id=session.subject,
include_all=include_all,
include_historical=include_historical and include_all,
status=normalized_status,
limit=limit,
)
Expand Down Expand Up @@ -2855,6 +2857,7 @@ async def dashboard_gig_detail_handler(
settings,
viewer_discord_user_id=session.subject,
include_all=include_all,
include_historical=True,
engagement_id=normalized_engagement_id,
limit=1,
)
Expand Down Expand Up @@ -2883,6 +2886,7 @@ async def dashboard_notifications_handler(
viewer_discord_user_id=session.subject,
include_all=include_all,
stale_days=settings.gig_recruiting_stale_days,
max_age_days=settings.gig_recruiting_reminder_max_age_days,
limit=limit,
)
return JSONResponse(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"index.html": {
"file": "assets/index-DAy1pv-J.js",
"file": "assets/index-DT8AhUVi.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"css": [
"assets/index-0wkHV6At.css"
"assets/index-D2CiH-sJ.css"
]
}
}

Large diffs are not rendered by default.

This file was deleted.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/api/src/five08/backend/static/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>508 Operations Dashboard</title>
<script type="module" crossorigin src="/dashboard/assets/index-DAy1pv-J.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-0wkHV6At.css">
<script type="module" crossorigin src="/dashboard/assets/index-DT8AhUVi.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-D2CiH-sJ.css">
</head>
<body>
<div id="root"></div>
Expand Down
38 changes: 34 additions & 4 deletions apps/discord_bot/src/five08/discord_bot/cogs/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2242,7 +2242,7 @@ async def _persist_thread_engagement_match(
try:
thread_name = str(getattr(thread, "name", "") or "")
starter_id = getattr(starter, "id", None) or thread.id
status = parse_status_from_title(thread_name)
status = self._status_for_thread(thread)
title = (
requirements.title
or strip_status_from_title(thread_name)
Expand Down Expand Up @@ -2270,6 +2270,7 @@ async def _persist_thread_engagement_match(
preferred_skills=requirements.preferred_skills,
requirements=requirements_to_payload(requirements),
preserve_existing_status=True,
refresh_activity=False,
),
)
saved = await asyncio.to_thread(
Expand Down Expand Up @@ -2327,12 +2328,31 @@ async def _upsert_thread_engagement(
body_raw=body,
body_normalized=body,
posted_at=getattr(post.starter, "created_at", None),
status=parse_status_from_title(thread_name),
status=self._status_for_thread(thread),
preserve_existing_status=True,
refresh_activity=refresh_activity,
),
)

@staticmethod
def _thread_looks_done(thread: discord.Thread) -> bool:
"""Treat closed Discord forum posts as no longer actively recruiting."""
return bool(
getattr(thread, "locked", False) or getattr(thread, "archived", False)
)

@classmethod
def _status_for_thread(cls, thread: discord.Thread) -> EngagementStatus:
explicit_status = parse_status_from_title(
str(getattr(thread, "name", "") or "")
)
if cls._thread_looks_done(thread) and explicit_status in {
EngagementStatus.UNKNOWN,
EngagementStatus.RECRUITING,
}:
return EngagementStatus.OUTDATED
return explicit_status

@staticmethod
async def _rename_gig_thread_for_status(
thread: discord.Thread,
Expand Down Expand Up @@ -2852,6 +2872,7 @@ async def _send_due_recruiting_reminders(self) -> None:
list_due_recruiting_reminders,
settings,
stale_days=settings.gig_recruiting_stale_days,
max_age_days=settings.gig_recruiting_reminder_max_age_days,
)
except Exception as exc:
logger.warning("Failed loading due recruiting reminders: %s", exc)
Expand All @@ -2869,6 +2890,15 @@ async def _send_due_recruiting_reminders(self) -> None:
thread = await self.bot.fetch_channel(int(thread_id))
if not isinstance(thread, discord.Thread):
continue
if self._thread_looks_done(thread):
await asyncio.to_thread(
update_engagement_status,
settings,
engagement_id=str(engagement_id),
status=EngagementStatus.OUTDATED,
actor_discord_user_id=None,
)
continue
age_days = int(
row.get("age_days") or settings.gig_recruiting_stale_days
)
Expand Down Expand Up @@ -2968,7 +2998,7 @@ async def _persist_thread_direct_interest(self, message: discord.Message) -> boo
body_raw=post.starter.content or None,
body_normalized=post.starter.content or None,
posted_at=getattr(post.starter, "created_at", None),
status=parse_status_from_title(thread_name),
status=self._status_for_thread(thread),
preserve_existing_status=True,
),
)
Expand Down Expand Up @@ -3027,7 +3057,7 @@ async def _persist_thread_reply_activity(self, message: discord.Message) -> None
body_raw=post.starter.content or None,
body_normalized=post.starter.content or None,
posted_at=getattr(post.starter, "created_at", None),
status=parse_status_from_title(thread_name),
status=self._status_for_thread(thread),
preserve_existing_status=True,
refresh_activity=True,
),
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ that most often matter in local development and deployment.
- `JOB_RETRY_BASE_SECONDS`
- `JOB_RETRY_MAX_SECONDS`
- `GIG_RECRUITING_STALE_DAYS`
- `GIG_RECRUITING_REMINDER_MAX_AGE_DAYS`

`./scripts/dev.sh` overrides local Redis settings to deterministic per-worktree
localhost ports. Compose injects Docker-network URLs.
Expand Down
8 changes: 8 additions & 0 deletions docs/discord-gig-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,24 @@ guards for phrases such as "not available" or "not interested".

`GIG_RECRUITING_STALE_DAYS` controls when a recruiting gig is considered stale.
The default is `7`.
`GIG_RECRUITING_REMINDER_MAX_AGE_DAYS` bounds reminders and stale notifications
to recently posted gigs. The default is `90`, so old backfilled Discord posts are
not treated as active recruiting work.

The dashboard notification tray uses `GET /dashboard/api/notifications` to show
recruiting gigs whose latest known activity is older than the configured
threshold.
The main gig list hides only historical terminal statuses (`LOST` and
`OUTDATED`) by default. Steering/admin viewers can opt into historical gigs when
they need those records.

The Discord bot also runs a periodic reminder loop. When a stale recruiting gig
has a Discord thread and original poster, it replies in the thread and mentions
the poster asking for a status update. Sent reminders update
`last_recruiting_reminder_at` but do not advance `last_activity_at`, so passive
reminders do not make stale gigs look active.
Locked or archived Discord gig threads are treated as done and marked outdated
instead of receiving a reminder.

Ordinary registered gig thread replies count as activity. This makes the stale
recruiting reminder instruction to "leave a thread reply if it is still active"
Expand Down
23 changes: 20 additions & 3 deletions packages/shared/src/five08/engagements.py
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@ def list_dashboard_engagements(
*,
viewer_discord_user_id: str | None,
include_all: bool,
include_historical: bool = False,
status: EngagementStatus | None = None,
engagement_id: str | None = None,
limit: int = 50,
Expand All @@ -846,6 +847,8 @@ def list_dashboard_engagements(
if status is not None:
conditions.append("e.status = %s")
params.append(status.value)
elif engagement_id is None and not include_historical:
conditions.append("e.status IN ('recruiting', 'filled', 'unknown')")
params.append(max(1, min(limit, 500)))
sql = f"""
SELECT
Expand Down Expand Up @@ -923,7 +926,15 @@ def list_dashboard_engagements(
)
WHERE {" AND ".join(conditions)}
GROUP BY e.id
ORDER BY e.last_activity_at DESC NULLS LAST, e.created_at DESC
ORDER BY
CASE e.status
WHEN 'recruiting' THEN 0
WHEN 'filled' THEN 1
WHEN 'unknown' THEN 2
ELSE 3
END ASC,
e.last_activity_at DESC NULLS LAST,
e.created_at DESC
LIMIT %s
"""
with get_postgres_connection(settings) as conn:
Expand All @@ -939,11 +950,13 @@ def list_dashboard_notifications(
viewer_discord_user_id: str | None,
include_all: bool,
stale_days: int,
max_age_days: int,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Return dashboard notification items visible to one viewer."""
days = max(1, stale_days)
params: list[Any] = [days]
max_age = max(days, max_age_days)
params: list[Any] = [days, max_age]
conditions = [
"e.lifecycle_stage = 'pending_gig'",
"e.status = 'recruiting'",
Expand All @@ -955,6 +968,7 @@ def list_dashboard_notifications(
e.created_at
) <= NOW() - make_interval(days => %s)
""",
"COALESCE(e.posted_at, e.created_at) >= NOW() - make_interval(days => %s)",
]
if not include_all:
conditions.append("e.posted_by_discord_user_id = %s")
Expand Down Expand Up @@ -1002,10 +1016,12 @@ def list_due_recruiting_reminders(
settings: SharedSettings,
*,
stale_days: int,
max_age_days: int,
limit: int = 25,
) -> list[dict[str, Any]]:
"""Atomically claim recruiting gig threads that need a Discord status reminder."""
days = max(1, stale_days)
max_age = max(days, max_age_days)
sql = """
WITH due AS (
SELECT e.id
Expand All @@ -1020,6 +1036,7 @@ def list_due_recruiting_reminders(
COALESCE(e.posted_at, '-infinity'::timestamptz),
e.created_at
) <= NOW() - make_interval(days => %s)
AND COALESCE(e.posted_at, e.created_at) >= NOW() - make_interval(days => %s)
AND (
e.last_recruiting_reminder_at IS NULL
OR e.last_recruiting_reminder_at <= NOW() - make_interval(days => %s)
Expand Down Expand Up @@ -1061,7 +1078,7 @@ def list_due_recruiting_reminders(
FROM claimed e
ORDER BY e.last_recruiting_reminder_at ASC NULLS FIRST, e.created_at ASC
"""
params = (days, days, max(1, min(limit, 100)))
params = (days, max_age, days, max(1, min(limit, 100)))
with get_postgres_connection(settings) as conn:
with conn.cursor(row_factory=dict_row) as cursor:
cursor.execute(sql, params)
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/five08/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class SharedSettings(BaseSettings):
job_timeout_seconds: int = 600
job_result_ttl_seconds: int = 3600
gig_recruiting_stale_days: int = Field(default=7, ge=1)
gig_recruiting_reminder_max_age_days: int = Field(default=90, ge=1)
minio_endpoint: str = "http://127.0.0.1:9000"
minio_root_user: str = "internal"
minio_root_password: str = ""
Expand Down
Loading
Loading