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
4 changes: 4 additions & 0 deletions app/globals.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 31 additions & 3 deletions app/join/page.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import { opportunities, safeUrl, pageContent } from '@/lib/content';
import { formatDate, opportunities, people, safeUrl } from '@/lib/content';
import { openOpportunities } from '@/lib/relationships.mjs';
import ReactMarkdown from 'react-markdown';
import PageHeading from '@/components/page-heading';
export const metadata = { title: 'Open positions', alternates: { canonical: '/join/' } };
export const metadata = {
title: 'Join us',
description: 'Current postdoctoral and PhD opportunities with the Behavioral Data Science Group at the University of Washington.',
alternates: { canonical: '/join/' },
};

export default function Join() {
const open = openOpportunities(opportunities);
return <><PageHeading title="Open positions" /><div className="prose">{open.length > 0 ? open.map(o => <section className="section" key={o.id}><h2>{o.title}</h2>{o.description && <ReactMarkdown>{o.description}</ReactMarkdown>}<a href={safeUrl(o.url)}>Apply</a></section>) : <ReactMarkdown>{pageContent.recruitment}</ReactMarkdown>}</div></>;
return <>
<PageHeading title="Join us">Explore current opportunities to work with the Behavioral Data Science Group at the University of Washington.</PageHeading>
{open.length > 0 ? <div className="opportunity-grid">{open.map(opportunity => {
const owner = people.find(person => person.id === opportunity.ownerId);
return <article className="opportunity-card" key={opportunity.id}>
<div className="opportunity-card-header">
<span className="opportunity-status">Open</span>
{opportunity.reviewedOn && <span className="opportunity-reviewed">Last verified {formatDate(opportunity.reviewedOn)}</span>}
</div>
<h2>{opportunity.title}</h2>
{owner && <p className="opportunity-owner">Faculty lead: {owner.name}</p>}
{opportunity.description && <div className="opportunity-description"><ReactMarkdown>{opportunity.description}</ReactMarkdown></div>}
{opportunity.meta?.length > 0 && <ul className="opportunity-meta">{opportunity.meta.map(item => <li key={item}>{item}</li>)}</ul>}
<div className="opportunity-actions">
<a className="button" href={safeUrl(opportunity.url)}>{opportunity.actionLabel}<span aria-hidden="true">↗</span></a>
{opportunity.sourceUrl && <a className="text-link" href={safeUrl(opportunity.sourceUrl)}>{opportunity.sourceLabel}</a>}
</div>
</article>;
})}</div> : <section className="empty opportunities-empty">
<h2>No currently advertised openings</h2>
<p>New opportunities will be posted here. You can also explore graduate programs at the Allen School.</p>
<a className="text-link" href="https://www.cs.washington.edu/academics/graduate/">Allen School graduate programs</a>
</section>}
</>;
}
2 changes: 1 addition & 1 deletion components/navigation.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const links = [['/', 'Home'], ['/team/', 'Team'], ['/publications/', 'Publications'], ['/news/', 'News'], ['/join/', 'Open positions']];
const links = [['/', 'Home'], ['/team/', 'Team'], ['/publications/', 'Publications'], ['/news/', 'News'], ['/join/', 'Join us']];
export default function Navigation({ extraLinks = [] }) {
const pathname = usePathname();
return <nav aria-label="Main navigation">{[...links, ...extraLinks].map(([href, title]) => <Link key={href} href={href} aria-current={pathname === href || pathname === href.slice(0, -1) || (href !== '/' && pathname.startsWith(href)) || (href === '/team/' && pathname.startsWith('/people/')) ? 'page' : undefined}>{title}</Link>)}</nav>;
Expand Down
36 changes: 35 additions & 1 deletion content/opportunities.json
Original file line number Diff line number Diff line change
@@ -1 +1,35 @@
[]
[
{
"id": "postdoctoral-scholars",
"title": "Postdoctoral Scholars",
"ownerId": "timalthoff",
"url": "https://docs.google.com/document/d/1h44Gz1wOMx6QhHUWv9QFB3Jnrn-JzEMPHC0WZhPMEqU/edit",
"status": "open",
"description": "Prof. Tim Althoff invites applications from researchers interested in AI safety, psychosocial simulation with AI, reducing psychosocial harms, and AI-supported mental health interventions.",
"meta": [
"Multiple positions",
"Applications reviewed on a rolling basis until filled"
],
"actionLabel": "View position details",
"reviewedOn": "2026-09-09",
"reviewOn": "2026-12-09"
},
{
"id": "prospective-phd-students",
"title": "Prospective PhD Students",
"ownerId": "timalthoff",
"url": "https://www.cs.washington.edu/academics/graduate/phd-program/phd-admissions/",
"status": "open",
"description": "Tim Althoff is recruiting a small number of PhD students with interests including deep learning, causal inference, data science, and mobile health. Students apply through the Allen School rather than directly to the lab.",
"meta": [
"Current application window: September 1–December 15, 2026",
"Autumn admission"
],
"actionLabel": "PhD admissions and application",
"sourceUrl": "https://homes.cs.washington.edu/~althoff/",
"sourceLabel": "Prospective student guidance",
"reviewedOn": "2026-09-09",
"reviewOn": "2026-12-15",
"closesOn": "2026-12-15"
}
]
2 changes: 1 addition & 1 deletion content/pages.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"home": "We are a research group at the [Paul G. Allen School of Computer Science of Engineering](http://cs.washington.edu). Our aim is to explore and understand behavior through the lens of data science.\n\nThe Behavioral Data Science Group develops computational methods that leverage large-scale behavioral data to extract actionable insights about our lives, health and happiness through combining techniques from data science, social network analysis, and natural language processing. We currently work on research related to mental health, misinformation online, scientific reproducibility, and informing the COVID-19 response.",
"recruitment": "We have a [postdoc position](https://docs.google.com/document/d/1h44Gz1wOMx6QhHUWv9QFB3Jnrn-JzEMPHC0WZhPMEqU/edit#) available.\n\nWe are also looking for PhD students, especially in but not limited to deep learning, causal inference, data science and mobile health. If you're interested please apply to the UW Allen School. Go Dawgs!",
"recruitment": "Current opportunities are maintained in the structured opportunities collection.",
"idiofid": "IdioFid-A is a benchmark for evaluating how well AI agents can infer a specific person’s attitudes from their real social media history. Leave your email to get a one-time notification when the benchmark, dataset, and code are public.",
"research": "",
"pictures": "",
Expand Down
4 changes: 2 additions & 2 deletions docs/CONTENT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ All public page data lives in `content/`. JSON strings may contain Markdown wher
| `awards.json` | `id`, `title`, `organization`, `date`, `sourceUrl`, at least one recipient or paper | `personIds`, `publicationIds`; one record supplies badges and related profile, publication, and news views. |
| `news.json` | `id`, `headline` (Markdown), `date` | `personIds`, `publicationIds`, `projectIds`, `awardIds`, `sourceUrl`. Related profiles and paper pages display linked news. |
| `projects.json` | `id`, `title` | `description` (Markdown), local `image`, `personIds`, `publicationIds`, `url`, `code`, `dataset`. Appears on Research and linked profile/paper pages. |
| `opportunities.json` | `id`, `title`, `ownerId`, `url`, `status`, `reviewOn` | `description` (Markdown), `closesOn`; status is open/closed/draft. Open entries past their closing date are excluded at build time. |
| `opportunities.json` | `id`, `title`, `ownerId`, `url`, `status`, `description`, `actionLabel`, `reviewedOn`, `reviewOn` | `meta` (short display facts), `sourceUrl`, `sourceLabel`, `closesOn`; status is open/closed/draft. Open entries past their closing date are excluded at build time. |
| `gallery.json` | `id`, local `image`, `alt` | `caption`, `date`, `personIds`; renders on Pictures. |
| `sponsors.json` | `name`, `path` relative to `/resources` | `url`, `reviewOn`; existing logo paths are preserved. |
| `alumni.json` | `name` | `site`; historical list retained pending roster reconciliation. |
Expand All @@ -22,7 +22,7 @@ Publication status: `preprint`, `accepted`, `published`, `withdrawn`, `retracted

Awards are not converted into invented news text. An approved news record can link to an award; its exact title is shown where related. Existing paper-level `award` strings remain supported until reviewed migration into the structured collection. Avoid duplicating an award across old and new records.

Recruitment retains its original Markdown while `opportunities.json` is empty. Once structured opportunities exist, they are the source for the recruitment page. Closed or draft entries remain in the data but are hidden. Expiration is evaluated when the static site is built, so changing a date or reaching a deadline requires a rebuild; the deployment runbook covers this.
`opportunities.json` is the sole source for the Join us page. Closed, draft, and expired entries remain in the data but are hidden. If none are open, the page renders a neutral empty state rather than a historical recruitment claim. `reviewedOn` is the last human verification date; `reviewOn` is the next date the listing owner should check the record. Expiration is evaluated when the static site is built, so changing a date or reaching a deadline requires a rebuild; the deployment runbook covers this.

Research and Pictures navigation entries appear when their respective collections contain records. Their routes remain valid while empty. No example record is shipped in public content; synthetic test records are isolated under `tests/fixtures/` or temporary directories.

Expand Down
6 changes: 5 additions & 1 deletion lib/content-validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@ export function validateContent(data, assetExists = () => true) {
}
for (const p of data.projects) { text(p.title,p.id); url(p.url,p.id); url(p.code,p.id);url(p.dataset,p.id);asset(p.image,p.id);common(p); }
for (const o of data.opportunities) {
for (const field of ['title','ownerId','url']) text(o[field], `${o.id}.${field}`);
for (const field of ['title','ownerId','url','description','actionLabel']) text(o[field], `${o.id}.${field}`);
if (!ids.people.has(o.ownerId)) fail(o.id, 'unknown ownerId');
if (!['open','closed','draft'].includes(o.status)) fail(o.id, 'invalid opportunity status');
if (!o.reviewedOn) fail(o.id, 'missing reviewedOn');
if (o.meta !== undefined) strings(o.meta, `${o.id}.meta`);
if (o.sourceUrl && !o.sourceLabel) fail(o.id, 'sourceUrl needs sourceLabel');
if (o.sourceLabel) { text(o.sourceLabel, `${o.id}.sourceLabel`); if (!o.sourceUrl) fail(o.id, 'sourceLabel needs sourceUrl'); }
date(o.reviewOn, o.id); if (o.closesOn) date(o.closesOn,o.id); url(o.url,o.id);common(o);
}
for (const p of data.gallery) { text(p.image,p.id);text(p.alt,p.id);asset(p.image,p.id);if (p.date) date(p.date,p.id);common(p); }
Expand Down
Binary file removed public/downloads/GeneralPostdoc_2019_v01.pdf
Binary file not shown.
Binary file removed public/downloads/PD.pdf
Binary file not shown.
Binary file removed public/downloads/PHD1.pdf
Binary file not shown.
Binary file removed public/downloads/PHD2.pdf
Binary file not shown.
Binary file removed public/downloads/PPMS_PhD_2019_v01.pdf
Binary file not shown.
11 changes: 6 additions & 5 deletions scripts/test-skeleton.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ def main():
write(root,'awards',[{'id':'fixture-award','title':'Fixture award','organization':'Fixture organization','date':'2026-01-01','sourceUrl':'https://example.org/award','personIds':[person],'publicationIds':[paper]}])
write(root,'projects',[{'id':'fixture-project','title':'Fixture project','personIds':[person],'publicationIds':[paper],'description':'Fixture project description','url':'https://example.org/project'}])
write(root,'news',[{'id':'fixture-news','headline':'Fixture news','date':'2026-01-02','personIds':[person],'publicationIds':[paper],'awardIds':['fixture-award']}])
write(root,'opportunities',[{'id':'fixture-open','title':'Fixture opening','ownerId':person,'url':'https://example.org/apply','status':'open','reviewOn':'2099-01-01'}, {'id':'fixture-closed','title':'Hidden closed opening','ownerId':person,'url':'https://example.org/closed','status':'closed','reviewOn':'2099-01-01'}])
common_opportunity = {'ownerId':person,'status':'open','description':'Fixture description','meta':['Fixture detail'],'actionLabel':'View details','reviewedOn':'2026-01-01','reviewOn':'2099-01-01'}
write(root,'opportunities',[{'id':'fixture-open','title':'Fixture opening','url':'https://example.org/apply',**common_opportunity}, {'id':'fixture-closed','title':'Hidden closed opening','url':'https://example.org/closed',**common_opportunity,'status':'closed'}])
write(root,'gallery',[{'id':'fixture-photo','image':people[0]['image'],'alt':'Fixture photo','caption':'Fixture caption'}])
# Webpack supports a shared dependency symlink outside this temporary project root.
build(root)
Expand All @@ -58,12 +59,12 @@ def main():
raise SystemExit('Closed opportunity rendered')
if (root/'out/maintenance').exists():
raise SystemExit('Review queue leaked to export')
write(root,'opportunities',[{'id':'fixture-closed','title':'Hidden closed opening','ownerId':person,'url':'https://example.org/closed','status':'closed','reviewOn':'2099-01-01'}])
write(root,'opportunities',[{'id':'fixture-closed','title':'Hidden closed opening','url':'https://example.org/closed',**common_opportunity,'status':'closed'}])
build(root)
join = (root/'out/join/index.html').read_text()
if 'We are also looking for PhD students' not in join or 'Hidden closed opening' in join:
raise SystemExit('All-closed opportunities did not render recruitment fallback')
print(f'Fixture build passed: {len(checks)} routes render related records; closed opportunities fall back to recruitment copy and review files stay hidden. Real content unchanged.')
if 'No currently advertised openings' not in join or 'Hidden closed opening' in join or 'We are also looking for PhD students' in join:
raise SystemExit('All-closed opportunities did not render the neutral empty state')
print(f'Fixture build passed: {len(checks)} routes render related records; closed opportunities use a neutral empty state and review files stay hidden. Real content unchanged.')


if __name__ == '__main__': main()