Skip to main content

max / makenotwork

server: landing "Last shipped" velocity line + show_on_landing Lane D launch work. Add an operator-controlled "Last shipped" line on the landing page, drawn from the most recent published changelog post flagged show_on_landing; suppressed entirely when none (no placeholder). - migration 136: show_on_landing on blog_posts - blog editor: owner-only "Show on landing" checkbox, changelog project only - landing route reads the most-recent eligible changelog post - mobile-aware sub-line under the browse CTA - tests: omit with zero eligible, surface most recent, ignore non-changelog Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-07 21:12 UTC
Commit: 2828007db53eb6474a3a5ab0e5e5a0a3d5616a9f
Parent: 132431d
14 files changed, +263 insertions, -4 deletions
@@ -0,0 +1,14 @@
1 + -- Operator-controlled flag marking a changelog post as landing-page-worthy.
2 + --
3 + -- The landing page surfaces a single "Last shipped" velocity line drawn from
4 + -- the most recent *published* blog post that (a) has this flag set and (b)
5 + -- belongs to the project whose slug is CHANGELOG_PROJECT_SLUG. Not every
6 + -- changelog entry warrants landing-page weight, so the operator opts a post in
7 + -- explicitly via the "Show on landing" checkbox in the blog editor.
8 + --
9 + -- The column lives on every blog_posts row (any project's editor can set it),
10 + -- but the landing reader filters by the changelog project slug, so the flag is
11 + -- inert on non-changelog projects. Defaults FALSE: existing posts stay off the
12 + -- landing page until deliberately flagged.
13 + ALTER TABLE blog_posts
14 + ADD COLUMN IF NOT EXISTS show_on_landing BOOLEAN NOT NULL DEFAULT FALSE;
@@ -21,13 +21,14 @@ pub async fn create_blog_post(
21 21 body_html: &str,
22 22 publish: bool,
23 23 web_only: bool,
24 + show_on_landing: bool,
24 25 ) -> Result<DbBlogPost> {
25 26 let published_at = if publish { Some(Utc::now()) } else { None };
26 27
27 28 let post = sqlx::query_as::<_, DbBlogPost>(
28 29 r#"
29 - INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at, web_only)
30 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
30 + INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at, web_only, show_on_landing)
31 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
31 32 RETURNING *
32 33 "#,
33 34 )
@@ -39,6 +40,7 @@ pub async fn create_blog_post(
39 40 .bind(body_html)
40 41 .bind(published_at)
41 42 .bind(web_only)
43 + .bind(show_on_landing)
42 44 .fetch_one(pool)
43 45 .await?;
44 46
@@ -126,6 +128,39 @@ pub async fn get_published_blog_posts_by_project(
126 128 Ok(posts)
127 129 }
128 130
131 + /// Fetch the single landing-page "Last shipped" post: the most recent
132 + /// published, landing-flagged post belonging to a public project with the
133 + /// given slug (CHANGELOG_PROJECT_SLUG). Returns `None` when nothing qualifies,
134 + /// which the landing route uses to suppress the velocity line entirely.
135 + ///
136 + /// `show_on_landing` is set on rows across every project, but the slug join
137 + /// confines the landing reader to the changelog project, so the flag is inert
138 + /// elsewhere.
139 + #[tracing::instrument(skip_all)]
140 + pub async fn get_landing_changelog_post(
141 + pool: &PgPool,
142 + changelog_slug: &str,
143 + ) -> Result<Option<DbBlogPost>> {
144 + let post = sqlx::query_as::<_, DbBlogPost>(
145 + r#"
146 + SELECT bp.*
147 + FROM blog_posts bp
148 + JOIN projects p ON p.id = bp.project_id
149 + WHERE p.slug = $1
150 + AND p.is_public = true
151 + AND bp.show_on_landing = true
152 + AND bp.published_at IS NOT NULL
153 + ORDER BY bp.published_at DESC
154 + LIMIT 1
155 + "#,
156 + )
157 + .bind(changelog_slug)
158 + .fetch_optional(pool)
159 + .await?;
160 +
161 + Ok(post)
162 + }
163 +
129 164 /// Update a blog post's fields.
130 165 ///
131 166 /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
@@ -145,6 +180,7 @@ pub async fn update_blog_post(
145 180 publish: bool,
146 181 publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
147 182 web_only: Option<bool>,
183 + show_on_landing: Option<bool>,
148 184 ) -> Result<DbBlogPost> {
149 185 let update_publish_at = publish_at.is_some();
150 186 let publish_at_value = publish_at.flatten();
@@ -169,6 +205,7 @@ pub async fn update_blog_post(
169 205 END,
170 206 publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END,
171 207 web_only = COALESCE($9, web_only),
208 + show_on_landing = COALESCE($10, show_on_landing),
172 209 updated_at = NOW()
173 210 WHERE id = $1
174 211 RETURNING *
@@ -183,6 +220,7 @@ pub async fn update_blog_post(
183 220 .bind(update_publish_at)
184 221 .bind(publish_at_value)
185 222 .bind(web_only)
223 + .bind(show_on_landing)
186 224 .fetch_one(pool)
187 225 .await?;
188 226
@@ -36,6 +36,9 @@ pub struct DbBlogPost {
36 36 pub mt_thread_id: Option<MtThreadId>,
37 37 /// Whether this post should only be published on the web (skip email announcements).
38 38 pub web_only: bool,
39 + /// Operator-only flag marking this post as the landing-page "Last shipped"
40 + /// line. Inert unless the post lives on the CHANGELOG_PROJECT_SLUG project.
41 + pub show_on_landing: bool,
39 42 /// When a release announcement was sent (prevents re-announcement on unpublish/republish).
40 43 pub release_announced_at: Option<DateTime<Utc>>,
41 44 }
@@ -32,6 +32,9 @@ pub struct CreateBlogPostRequest {
32 32 pub is_published: Option<bool>,
33 33 /// Whether to skip email announcements when publishing.
34 34 pub web_only: Option<bool>,
35 + /// Operator-only: surface this post as the landing "Last shipped" line.
36 + /// Only meaningful on the changelog project; inert elsewhere.
37 + pub show_on_landing: Option<bool>,
35 38 }
36 39
37 40 /// JSON input for updating a blog post.
@@ -45,6 +48,9 @@ pub struct UpdateBlogPostRequest {
45 48 pub publish_at: Option<String>,
46 49 /// Whether to skip email announcements when publishing.
47 50 pub web_only: Option<bool>,
51 + /// Operator-only: surface this post as the landing "Last shipped" line.
52 + /// `None` leaves the existing flag unchanged (e.g. autosave).
53 + pub show_on_landing: Option<bool>,
48 54 }
49 55
50 56 /// JSON response representing a blog post.
@@ -57,6 +63,7 @@ pub struct BlogPostResponse {
57 63 pub is_published: bool,
58 64 pub published_at: Option<String>,
59 65 pub web_only: bool,
66 + pub show_on_landing: bool,
60 67 pub created_at: String,
61 68 pub updated_at: String,
62 69 }
@@ -71,6 +78,7 @@ pub struct BlogPostEditResponse {
71 78 pub is_published: bool,
72 79 pub publish_at: Option<String>,
73 80 pub web_only: bool,
81 + pub show_on_landing: bool,
74 82 }
75 83
76 84 fn blog_post_edit_response(post: &db::DbBlogPost) -> BlogPostEditResponse {
@@ -82,6 +90,7 @@ fn blog_post_edit_response(post: &db::DbBlogPost) -> BlogPostEditResponse {
82 90 is_published: post.published_at.is_some(),
83 91 publish_at: post.publish_at.map(|d| d.to_rfc3339()),
84 92 web_only: post.web_only,
93 + show_on_landing: post.show_on_landing,
85 94 }
86 95 }
87 96
@@ -94,6 +103,7 @@ fn blog_post_response(post: &db::DbBlogPost) -> BlogPostResponse {
94 103 is_published: post.published_at.is_some(),
95 104 published_at: post.published_at.map(|d| d.to_rfc3339()),
96 105 web_only: post.web_only,
106 + show_on_landing: post.show_on_landing,
97 107 created_at: post.created_at.to_rfc3339(),
98 108 updated_at: post.updated_at.to_rfc3339(),
99 109 }
@@ -153,13 +163,14 @@ pub(super) async fn create_blog_post(
153 163 // Retry with suffixes if a concurrent request creates the same slug
154 164 // between our existence check and insert (TOCTOU race).
155 165 let web_only = req.web_only.unwrap_or(false);
166 + let show_on_landing = req.show_on_landing.unwrap_or(false);
156 167
157 168 let base_slug = slug.clone();
158 169 let mut suffix = 1u32;
159 170 let post = loop {
160 171 match db::blog_posts::create_blog_post(
161 172 &state.db, project_id, user.id, &req.title, &slug,
162 - body_markdown, &body_html, is_published, web_only,
173 + body_markdown, &body_html, is_published, web_only, show_on_landing,
163 174 ).await {
164 175 Ok(post) => break post,
165 176 Err(e) => {
@@ -242,6 +253,7 @@ pub(super) async fn update_blog_post(
242 253 is_published,
243 254 publish_at,
244 255 req.web_only,
256 + req.show_on_landing,
245 257 )
246 258 .await?;
247 259
@@ -150,6 +150,7 @@ pub(super) async fn create_blog_post(
150 150 &body_html,
151 151 publish,
152 152 false,
153 + false,
153 154 )
154 155 .await?;
155 156
@@ -171,6 +172,7 @@ pub(super) async fn create_blog_post(
171 172 false, // not published yet — scheduler handles it
172 173 Some(Some(dt_utc)),
173 174 None,
175 + None,
174 176 )
175 177 .await?
176 178 } else {
@@ -152,6 +152,10 @@ pub(super) async fn blog_editor(
152 152
153 153 let csrf_token = get_csrf_token(&session).await;
154 154
155 + // The landing "Show on landing" control only applies to the platform
156 + // changelog project; surfacing it elsewhere would expose an inert flag.
157 + let is_changelog_project = db_project.slug.as_str() == crate::constants::CHANGELOG_PROJECT_SLUG;
158 +
155 159 // If editing an existing post, load it
156 160 if let Some(post_id_str) = &query.post {
157 161 let post_id: BlogPostId = post_id_str.parse().map_err(|_| AppError::NotFound)?;
@@ -174,6 +178,8 @@ pub(super) async fn blog_editor(
174 178 post_slug: post.slug.to_string(),
175 179 post_body: post.body_markdown,
176 180 post_is_published: post.published_at.is_some(),
181 + is_changelog_project,
182 + post_show_on_landing: post.show_on_landing,
177 183 });
178 184 }
179 185
@@ -189,5 +195,7 @@ pub(super) async fn blog_editor(
189 195 post_slug: String::new(),
190 196 post_body: String::new(),
191 197 post_is_published: false,
198 + is_changelog_project,
199 + post_show_on_landing: false,
192 200 })
193 201 }
@@ -45,6 +45,23 @@ pub(super) async fn index(
45 45 let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32;
46 46 let total_items = db::items::count_public_listed(&state.db).await?;
47 47
48 + // "Last shipped" velocity line: most recent published, landing-
49 + // flagged post on the changelog project. Read once per render; the
50 + // line is suppressed entirely when nothing qualifies (no
51 + // placeholder), matching the runway disclosure's no-fabrication rule.
52 + let last_shipped = db::blog_posts::get_landing_changelog_post(
53 + &state.db,
54 + constants::CHANGELOG_PROJECT_SLUG,
55 + )
56 + .await?
57 + .and_then(|post| {
58 + post.published_at.map(|published_at| LandingVelocity {
59 + title: post.title,
60 + date: published_at.format("%b %d, %Y").to_string(),
61 + href: format!("/changelog/{}", post.slug),
62 + })
63 + });
64 +
48 65 // Surface remaining founder slots only when close enough to feel
49 66 // scarce. 200 is "last chunk" — enough warning to convert, not so
50 67 // early that the number stays prominent for months.
@@ -88,6 +105,7 @@ pub(super) async fn index(
88 105 founder_slots_remaining,
89 106 tier_prices: state.tier_prices.clone(),
90 107 landing_carousel,
108 + last_shipped,
91 109 }.into_response())
92 110 }
93 111 }
@@ -277,6 +277,12 @@ pub struct BlogEditorTemplate {
277 277 pub post_slug: String,
278 278 pub post_body: String,
279 279 pub post_is_published: bool,
280 + /// Whether this editor is for the platform changelog project. Gates the
281 + /// owner-only "Show on landing" control so it never appears on a regular
282 + /// creator's blog, where the flag would be inert.
283 + pub is_changelog_project: bool,
284 + /// Current value of the post's landing flag (false for new posts).
285 + pub post_show_on_landing: bool,
280 286 }
281 287
282 288 /// HTMX partial: onboarding checklist (returned by the restore handler).
@@ -59,6 +59,20 @@ pub struct IndexTemplate {
59 59 /// placeholders; swap the images (and tighten the alt text) once real
60 60 /// captures exist. Empty hides the section.
61 61 pub landing_carousel: Vec<super::CarouselFrame>,
62 + /// The "Last shipped" velocity line, drawn from the most recent published,
63 + /// landing-flagged changelog post. `None` suppresses the line entirely
64 + /// (no placeholder) — same honesty rule the runway disclosure uses.
65 + pub last_shipped: Option<LandingVelocity>,
66 + }
67 +
68 + /// One-line "Last shipped" velocity signal for the landing page.
69 + pub struct LandingVelocity {
70 + /// Post title.
71 + pub title: String,
72 + /// Publication date, preformatted (e.g. "Jun 07, 2026").
73 + pub date: String,
74 + /// Link target: `/changelog/{slug}`.
75 + pub href: String,
62 76 }
63 77
64 78 /// User's library shell with inline purchases tab (other tabs loaded via HTMX).
@@ -23,6 +23,9 @@
23 23 };
24 24 }
25 25
26 + // Present only on the changelog project editor; null elsewhere.
27 + var landingToggle = document.getElementById('post-show-on-landing');
28 +
26 29 function goBack() {
27 30 window.location.href = '/dashboard/project/' + projectSlug;
28 31 }
@@ -35,6 +38,7 @@
35 38 }
36 39 var payload = { title: f.title, body_markdown: f.body, is_published: publish };
37 40 if (f.slug) payload.slug = f.slug;
41 + if (landingToggle) payload.show_on_landing = landingToggle.checked;
38 42
39 43 fetch('/api/projects/' + projectId + '/blog', {
40 44 method: 'POST',
@@ -62,10 +66,12 @@
62 66 postStatus.innerHTML = '<span style="color: var(--danger);">Slug is required</span>';
63 67 return;
64 68 }
69 + var updatePayload = { title: f.title, slug: f.slug, body_markdown: f.body, is_published: publish };
70 + if (landingToggle) updatePayload.show_on_landing = landingToggle.checked;
65 71 fetch('/api/blog/' + postId, {
66 72 method: 'PUT',
67 73 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
68 - body: JSON.stringify({ title: f.title, slug: f.slug, body_markdown: f.body, is_published: publish })
74 + body: JSON.stringify(updatePayload)
69 75 })
70 76 .then(function(res) {
71 77 if (!res.ok) return apiErrorMessage(res, 'Failed to update post').then(function(m) { throw new Error(m); });
@@ -5832,6 +5832,13 @@ textarea:focus-visible {
5832 5832 margin: 1.25rem 0 0;
5833 5833 }
5834 5834
5835 + .landing-mobile-note {
5836 + text-align: center;
5837 + margin: 0.4rem 0 0;
5838 + font-size: 0.8rem;
5839 + color: var(--text-muted);
5840 + }
5841 +
5835 5842 .founder-tagline {
5836 5843 font-family: var(--font-body);
5837 5844 font-size: 0.95rem;
@@ -5845,6 +5852,24 @@ textarea:focus-visible {
5845 5852 gap: 0.5rem;
5846 5853 }
5847 5854
5855 + .landing-velocity {
5856 + font-family: var(--font-mono);
5857 + font-size: 0.8rem;
5858 + text-align: center;
5859 + margin: 1rem auto 0;
5860 + color: var(--text-muted);
5861 + }
5862 +
5863 + .landing-velocity a {
5864 + color: var(--text);
5865 + text-decoration: none;
5866 + border-bottom: 1px solid var(--border);
5867 + }
5868 +
5869 + .landing-velocity a:hover {
5870 + border-bottom-color: var(--text);
5871 + }
5872 +
5848 5873 .founder-tagline-detail {
5849 5874 display: block;
5850 5875 }
@@ -39,6 +39,15 @@
39 39 <textarea id="post-body" rows="20" class="blog-editor-input input--sm w-full" placeholder="Write your post in Markdown...">{{ post_body }}</textarea>
40 40 <button type="button" class="btn-secondary blog-editor-media-btn" onclick="mediaPickerOpen('post-body')">Insert Image</button>
41 41 </div>
42 + {% if is_changelog_project %}
43 + <div class="form-group blog-editor-landing-toggle">
44 + <label for="post-show-on-landing">
45 + <input type="checkbox" id="post-show-on-landing"{% if post_show_on_landing %} checked{% endif %}>
46 + Show on landing
47 + </label>
48 + <p class="hint">Surfaces this post as the "Last shipped" line on the landing page. The most recent published post with this set wins; uncheck to retire it.</p>
49 + </div>
50 + {% endif %}
42 51 <div class="blog-editor-actions">
43 52 <button class="btn-primary" id="save-draft-btn">{% if editing %}Save as Draft{% else %}Save Draft{% endif %}</button>
44 53 <button class="btn-secondary" id="publish-btn">{% if editing && post_is_published %}Update{% else %}Publish{% endif %}</button>
@@ -29,6 +29,10 @@
29 29 </p>
30 30 {% endif %}
31 31
32 + {% if let Some(shipped) = last_shipped %}
33 + <p class="landing-velocity">Last shipped: <a href="{{ shipped.href }}">{{ shipped.title }}</a> &middot; {{ shipped.date }}</p>
34 + {% endif %}
35 +
32 36 <ul class="do-rail" aria-label="What we offer">
33 37 <li class="do-card">
34 38 <h3 class="do-card-title">A complete storefront</h3>
@@ -137,6 +141,7 @@
137 141 <p class="landing-browse-cta">
138 142 <a class="fork-secondary-link" href="/discover">Browse creators &rarr;</a>
139 143 </p>
144 + <p class="landing-mobile-note">Works in any browser, on any phone. No app to install.</p>
140 145
141 146 {% if total_creators > 0 || total_items > 0 %}
142 147 <p class="landing-stats-line">
@@ -293,3 +293,102 @@ async fn blog_post_list_respects_visibility() {
293 293 assert_eq!(resp.status, 200, "Published post should be accessible at {}", blog_url);
294 294 assert!(resp.text.contains("Public Post"), "Page should contain the post title");
295 295 }
296 +
297 + /// The landing "Last shipped" velocity line: suppressed with no eligible post,
298 + /// surfaced for the most recent flagged changelog post, and never fed by
299 + /// landing-flagged posts on non-changelog projects.
300 + #[tokio::test]
301 + async fn landing_velocity_line() {
302 + let mut h = TestHarness::new().await;
303 +
304 + // State 1: no eligible post on a fresh platform — line is suppressed.
305 + let resp = h.client.get("/").await;
306 + assert_eq!(resp.status, 200, "Landing should render: {}", resp.text);
307 + assert!(
308 + !resp.text.contains("Last shipped:"),
309 + "Velocity line must be absent with zero eligible posts"
310 + );
311 +
312 + // Creator owns both a non-changelog project and the changelog project.
313 + let user_id = h.signup("shipper", "shipper@example.com", "password123").await;
314 + h.grant_creator(user_id).await;
315 + h.client.post_form("/logout", "").await;
316 + h.login("shipper", "password123").await;
317 +
318 + // A non-changelog project with a landing-flagged, published post.
319 + let resp = h
320 + .client
321 + .post_form("/api/projects", "slug=updates&title=Updates")
322 + .await;
323 + let updates: Value = resp.json();
324 + let updates_id = updates["id"].as_str().unwrap();
325 + h.client
326 + .put_json(&format!("/api/projects/{}", updates_id), r#"{"is_public": true}"#)
327 + .await;
328 + let resp = h
329 + .client
330 + .post_json(
331 + &format!("/api/projects/{}/blog", updates_id),
332 + r#"{"title": "Off-topic note", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
333 + )
334 + .await;
335 + assert!(resp.status.is_success(), "Create flagged non-changelog post failed: {}", resp.text);
336 +
337 + // State 2: a flagged post on a non-changelog project is ignored by the
338 + // landing reader (which filters by the changelog slug).
339 + h.client.post_form("/logout", "").await;
340 + let resp = h.client.get("/").await;
341 + assert!(
342 + !resp.text.contains("Last shipped:"),
343 + "Landing-flagged post on a non-changelog project must not surface"
344 + );
345 + assert!(
346 + !resp.text.contains("Off-topic note"),
347 + "Non-changelog post title must not appear on the landing page"
348 + );
349 +
350 + // The changelog project with a landing-flagged, published post.
351 + h.login("shipper", "password123").await;
352 + let resp = h
353 + .client
354 + .post_form("/api/projects", "slug=changelog&title=Changelog")
355 + .await;
356 + let changelog: Value = resp.json();
357 + let changelog_id = changelog["id"].as_str().unwrap();
358 + h.client
359 + .put_json(&format!("/api/projects/{}", changelog_id), r#"{"is_public": true}"#)
360 + .await;
361 + let resp = h
362 + .client
363 + .post_json(
364 + &format!("/api/projects/{}/blog", changelog_id),
365 + r#"{"title": "Shipped gallery widget", "body_markdown": "Body", "is_published": true, "show_on_landing": true}"#,
366 + )
367 + .await;
368 + assert!(resp.status.is_success(), "Create flagged changelog post failed: {}", resp.text);
369 + let post: Value = resp.json();
370 + let post_slug = post["slug"].as_str().unwrap();
371 + assert_eq!(
372 + post["show_on_landing"].as_bool(),
373 + Some(true),
374 + "Create response should echo the landing flag"
375 + );
376 +
377 + // State 3: the changelog post surfaces as the velocity line.
378 + h.client.post_form("/logout", "").await;
379 + let resp = h.client.get("/").await;
380 + assert!(
381 + resp.text.contains("Last shipped:"),
382 + "Velocity line should appear once a changelog post is flagged: {}",
383 + resp.text
384 + );
385 + assert!(
386 + resp.text.contains("Shipped gallery widget"),
387 + "Velocity line should carry the post title"
388 + );
389 + assert!(
390 + resp.text.contains(&format!("/changelog/{}", post_slug)),
391 + "Velocity line should link to /changelog/{}",
392 + post_slug
393 + );
394 + }