diff --git a/app/globals.css b/app/globals.css index 07809ff..017914c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -21,3 +21,7 @@ .filters .field{min-width:140px;max-width:100%;flex:1}.filters .field.search{min-width:210px;flex:2}.filters select{width:100%;max-width:100%}.people-grid figure{margin:0}.people-grid figcaption{font-size:14px;margin-top:8px} .paper-card h2{font-size:22px;font-weight:500;margin:12px 0;line-height:1.35;max-width:820px}.person-card h2{font-size:19px;font-weight:500;margin:15px 0 5px} + +.opportunity-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px;margin:8px 0 70px}.opportunity-card{border:1px solid var(--line);padding:30px;display:flex;flex-direction:column;min-height:450px}.opportunity-card-header{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:30px;font-size:13px}.opportunity-status{display:inline-flex;align-items:center;gap:8px;color:var(--purple);font-weight:700}.opportunity-status::before{content:"";width:8px;height:8px;border-radius:50%;background:#2f7d4d}.opportunity-reviewed{color:var(--muted);text-align:right}.opportunity-card h2{font-size:31px;margin-bottom:8px}.opportunity-owner{font-size:14px;color:var(--muted);margin-bottom:24px}.opportunity-description{font-size:16px}.opportunity-description p{margin-bottom:22px}.opportunity-meta{list-style:none;padding:0;margin:0 0 30px;border-top:1px solid var(--line)}.opportunity-meta li{padding:11px 0;border-bottom:1px solid var(--line);font-size:14px}.opportunity-meta li::before{content:"—";color:var(--purple);margin-right:9px}.opportunity-actions{display:flex;align-items:center;gap:18px 24px;flex-wrap:wrap;margin-top:auto}.opportunity-actions .button{text-decoration:none}.opportunity-actions .text-link{text-decoration:underline;text-underline-offset:4px}.opportunities-empty{max-width:780px;margin-bottom:70px}.opportunities-empty h2{font-size:29px}.opportunities-empty p{max-width:620px} +@media(max-width:900px){.opportunity-grid{grid-template-columns:1fr}.opportunity-card{min-height:0}} +@media(max-width:760px){.opportunity-grid{margin-bottom:45px}.opportunity-card{padding:23px}.opportunity-card-header{align-items:flex-start;margin-bottom:24px}.opportunity-card h2{font-size:27px}.opportunity-reviewed{max-width:150px}.opportunity-actions{align-items:flex-start;flex-direction:column}.opportunity-actions .button{width:100%}} diff --git a/app/join/page.jsx b/app/join/page.jsx index 72f3e06..bc32a90 100644 --- a/app/join/page.jsx +++ b/app/join/page.jsx @@ -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 <>
{open.length > 0 ? open.map(o =>

{o.title}

{o.description && {o.description}}Apply
) : {pageContent.recruitment}}
; + return <> + Explore current opportunities to work with the Behavioral Data Science Group at the University of Washington. + {open.length > 0 ?
{open.map(opportunity => { + const owner = people.find(person => person.id === opportunity.ownerId); + return
+
+ Open + {opportunity.reviewedOn && Last verified {formatDate(opportunity.reviewedOn)}} +
+

{opportunity.title}

+ {owner &&

Faculty lead: {owner.name}

} + {opportunity.description &&
{opportunity.description}
} + {opportunity.meta?.length > 0 &&
    {opportunity.meta.map(item =>
  • {item}
  • )}
} +
+ {opportunity.actionLabel} + {opportunity.sourceUrl && {opportunity.sourceLabel}} +
+
; + })}
:
+

No currently advertised openings

+

New opportunities will be posted here. You can also explore graduate programs at the Allen School.

+ Allen School graduate programs +
} + ; } diff --git a/components/navigation.jsx b/components/navigation.jsx index 6fed6e3..3aeb542 100644 --- a/components/navigation.jsx +++ b/components/navigation.jsx @@ -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 ; diff --git a/content/opportunities.json b/content/opportunities.json index fe51488..00a767d 100644 --- a/content/opportunities.json +++ b/content/opportunities.json @@ -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" + } +] diff --git a/content/pages.json b/content/pages.json index a258e6c..fac75dd 100644 --- a/content/pages.json +++ b/content/pages.json @@ -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": "", diff --git a/docs/CONTENT_MODEL.md b/docs/CONTENT_MODEL.md index 5861ee8..6793637 100644 --- a/docs/CONTENT_MODEL.md +++ b/docs/CONTENT_MODEL.md @@ -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. | @@ -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. diff --git a/lib/content-validation.mjs b/lib/content-validation.mjs index 7db3150..c7cff70 100644 --- a/lib/content-validation.mjs +++ b/lib/content-validation.mjs @@ -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); } diff --git a/public/downloads/GeneralPostdoc_2019_v01.pdf b/public/downloads/GeneralPostdoc_2019_v01.pdf deleted file mode 100644 index 633f5ac..0000000 Binary files a/public/downloads/GeneralPostdoc_2019_v01.pdf and /dev/null differ diff --git a/public/downloads/PD.pdf b/public/downloads/PD.pdf deleted file mode 100644 index cec102d..0000000 Binary files a/public/downloads/PD.pdf and /dev/null differ diff --git a/public/downloads/PHD1.pdf b/public/downloads/PHD1.pdf deleted file mode 100644 index 8d91593..0000000 Binary files a/public/downloads/PHD1.pdf and /dev/null differ diff --git a/public/downloads/PHD2.pdf b/public/downloads/PHD2.pdf deleted file mode 100644 index fc4a564..0000000 Binary files a/public/downloads/PHD2.pdf and /dev/null differ diff --git a/public/downloads/PPMS_PhD_2019_v01.pdf b/public/downloads/PPMS_PhD_2019_v01.pdf deleted file mode 100644 index 41bc46e..0000000 Binary files a/public/downloads/PPMS_PhD_2019_v01.pdf and /dev/null differ diff --git a/scripts/test-skeleton.py b/scripts/test-skeleton.py index dc76dfb..717f52e 100644 --- a/scripts/test-skeleton.py +++ b/scripts/test-skeleton.py @@ -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) @@ -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()