บทเรียน 126 · Rails · Day 3

Lab Vibe 1 — หน้า index และ show ที่อ่านรู้เรื่อง

~30 นาทีอ่าน + ลงมือทำตามอีก ~45 นาที · ควรผ่าน บท 120 (เปิดโปรเจกต์) · บท 122–125 (ERB · partial · helper · XSS) มาก่อน

บทนี้เป็น Lab แรกของ Day 3 — ไม่มีเรื่องใหม่ให้จำมาก แต่จะเอาทุกอย่างของบท 122–125 มาเปลี่ยนหน้ารายการบทความ จาก "ตารางดิบของ scaffold ที่โชว์หมวดเป็นเลข 1" ให้เป็นหน้าที่คนอ่านรู้เรื่อง: มีชื่อหมวด · ตัดข้อความยาว · บอกว่าเขียนเมื่อไหร่ · แบ่งเป็นหน้า ๆ ละ 10 ชิ้น
อ่านจบแล้วคุณจะ ทำหน้า index กับ show เองได้ทีละขั้น · ตรวจรับงานด้วย checklist ที่ใช้เวลาไม่กี่นาที (นับ query · grep หาคำอันตราย) และมีไฟล์ prompts/01-views.md ไว้สั่ง AI ทำงานแบบเดียวกันแล้วตรวจด้วย checklist ชุดเดิม

🔁 ทวนก่อนเริ่ม — Lab นี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน

🍜 เริ่มจากโค้ดสถานะไหน: โปรเจกต์ wongnok-blog-all-in-one ตอนจบบท 120 (หมวด 5 · บทความ 30 จาก seeds) · บท 121–125 ที่ให้ทดลองแก้ ต้อง git restore ไปแล้ว · จบบทนี้โค้ดของคุณจะตรงกับ "จุดเช็คพอยต์ A" ที่บทถัดไป (127–129) ใช้ต่อ

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · pagy 43.8.1 · bullet 8.3.1 (สไลด์กับ repo ของอาจารย์เขียน Ruby 4.0.5 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)

ผลรันทุกก้อนในบทนี้รันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น เวลา ms · เลข id · วันเวลา · เลข commit · "1 day ago" ของคุณจะต่าง (seed ตั้งวันที่นับจากวันที่คุณรัน) · log ตัดรหัสสีออกแล้ว · path ยาว ๆ ย่อเป็น …

แผนที่ของ Lab: ตอนนี้มีอะไร จบแล้วจะมีอะไร

สไลด์หน้า 20 สรุปไว้สองฝั่ง · ฝั่งซ้ายคือหน้าที่ scaffold ให้มา (ใช้ได้ แต่คนอ่านไม่รู้เรื่อง) · ฝั่งขวาคือเป้าหมายของบทนี้

ตอนนี้ (scaffold ดิบ)

หน้า /posts โชว์ Title: Description: Category: 1 เรียงกันทุกชิ้น · ข้อความยาวโชว์เต็ม · 30 ชิ้นอยู่หน้าเดียว · หน้า show มีหัวข้อว่า "Showing post"

จบ Lab (จุดเช็คพอยต์ A)

หน้า index เป็นรายการอ่านง่าย พร้อมชื่อหมวด · ตัดข้อความเหลือ ~80 ตัวอักษร · "2 days ago" · หน้าละ 10 ชิ้นมีปุ่มเปลี่ยนหน้า · หน้า show มีทางกลับ (บทความทั้งหมด › หมวด › หัวข้อ) · partial _post ตัวเดียวใช้ทั้งสองหน้า

ขั้น 0 เตรียมร้าน git สะอาด · เปิด bin/dev · ดูหน้าเดิมกับ log │ ขั้น 1 ติดตั้ง pagy ตัวช่วยแบ่งหน้า (gem) │ ขั้น 2 controller includes + recent + pagy → ดู log ว่ากี่ query │ ขั้น 3 partial _post แม่พิมพ์ที่รับของผ่าน locals (บท 123 · 124) │ ขั้น 4 index + แถบเปลี่ยนหน้า render @posts · shared/_pagination │ ขั้น 5 show + breadcrumb render @post, full: true │ ขั้น 6 ฟอร์ม เลือกหมวดจากรายการ แทนพิมพ์เลข │ ขั้น 7 เวลาไทย · prompt config.time_zone · prompts/01-views.md │ ตรวจรับ checklist ของสไลด์หน้า 21 ทีละข้อ → ใส่บั๊กกลับเพื่อเชื่อตัวตรวจ → git commit

ทำไมต้องมีบทความถึง 30 ชิ้น (ที่เราใส่ไว้ตั้งแต่บท 120): สไลด์หน้า 20 บอกว่าปัญหาอย่าง N+1 หรือการแบ่งหน้าไม่โผล่ตอนมีข้อมูล 3 แถว · ใน Lab นี้คุณจะเห็นเองว่าถ้าลืม includes จำนวน query กระโดดจาก 3 เป็น 12 — กับข้อมูล 3 แถว มันต่างกันนิดเดียวจนไม่มีใครสังเกต

ขั้น 0 — เตรียมร้านก่อนเริ่ม

เปิด terminal สองแท็บในโฟลเดอร์โปรเจกต์ · แท็บแรกเปิดร้าน แท็บที่สองไว้พิมพ์คำสั่ง

# แท็บที่ 1 — เปิดร้านพร้อมตัว build CSS (บท 120 · ละเอียดในบท 128)
$ bin/dev

# แท็บที่ 2 — ต้องไม่ขึ้นอะไรเลย = ไม่มีของค้างจากบท 121–125
$ git status --short

ทำไมต้องสะอาด: ตอนท้าย Lab เราจะ git commit ทั้งหมดเป็นก้อนเดียว · ถ้ามีของทดลองค้างจากบทก่อนจะปนเข้าไปด้วย · ถ้ายังมีของค้าง ให้ git restore . (คืนไฟล์ที่แก้) แล้ว git status --short อีกรอบ

เปิด http://localhost:3000/posts ดูของเดิมก่อน · แต่ละชิ้นจะมีบรรทัด Category: ตามด้วยเลข เช่น 1 · แล้วดู log ของ request นั้น (ไฟล์ log/development.log หรือหน้าจอแท็บที่ 1) — ผลจริง ตัดมาแค่บรรทัดที่ไปตู้:

  Post Load (2.4ms)  SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/

อ่านบรรทัดนี้: Post Load = "ไปหยิบบทความจากตู้" · (2.4ms) = ใช้เวลาเท่าไหร่ (ของคุณจะต่าง) · ก้อน /*action='index',…*/ ท้ายบรรทัดคือคอมเมนต์ที่ Rails แปะบอกว่า SQL นี้มาจาก action ไหน — ไม่ต้องสนใจ ต่อจากนี้จะย่อเป็น /*…*/

ไม่มี LIMIT = หยิบทุกแฟ้มในลิ้นชัก มาจัดจานพร้อมกัน · ตอนนี้ 30 ชิ้นยังไม่เป็นไร แต่ถ้าวันหนึ่งมี 30,000 ชิ้น หน้านี้จะช้าจนใช้ไม่ได้ — นี่คือกฎข้อ 6 ใน prompts/00-context.md ("index action ต้องมี limit หรือ pagination") ที่ scaffold ไม่รู้ (บท 121)

ขั้น 1 — ติดตั้ง pagy: ตัวช่วยแบ่งรายการเป็นหน้า ๆ

pagination คือการแบ่งรายการยาว ๆ เป็นหน้า ๆ — หน้า 1 โชว์ชิ้นที่ 1–10 · หน้า 2 โชว์ 11–20 · มีปุ่ม "ถัดไป / ก่อนหน้า" · ใน SQL ทำด้วยสองคำ: LIMIT 10 (เอาแค่ 10 แผ่น) กับ OFFSET 10 (ข้าม 10 แผ่นแรกก่อน)

แล้วทำไมไม่เขียน Post.limit(10).offset(...) เอง? เพราะของที่ต้องคิดเพิ่มเยอะกว่าที่เห็น: ผู้ใช้พิมพ์ ?page=0 · ?page=abc · ?page=99 มาจะเป็นยังไง · ทั้งหมดมีกี่หน้า · หน้าสุดท้ายต้องซ่อนปุ่ม "ถัดไป" · ลิงก์ของแต่ละปุ่มต้องเป็น URL อะไร · สไลด์หน้า 22 จัด "ทำ pagination เองด้วย offset แทนที่จะใช้ gem" ไว้ในรายการที่ AI พลาดประจำ
gem คือแพ็กเกจโค้ดสำเร็จรูปของ Ruby ที่คนอื่นเขียนและทดสอบมาแล้ว (เหมือน module ของ Go ที่ go get มา) · ตัวที่เราใช้ชื่อ pagy

🍽 เทียบให้เห็นภาพ — หยิบแฟ้มจากตู้ทีละปึก

ลิ้นชัก posts มีแฟ้ม 30 แผ่น · ถ้าหยิบมาวางบนจานทีเดียวทั้งหมด จานล้น ลูกค้าอ่านไม่ไหว (และถ้ามี 30,000 แผ่น คนหยิบก็หมดแรง) · pagy คือผู้ช่วยที่หยิบทีละปึก: ก่อนอื่นนับว่ามีกี่แผ่น (จะได้บอกว่า "มีทั้งหมด 3 ปึก") แล้วหยิบมาเฉพาะปึกที่ลูกค้าขอ ปึกละ 10 แผ่น — เหมือนผลค้นหาสินค้าในแอปช้อปปิ้งที่มีเลขหน้า 1 2 3 ด้านล่าง · ลูกค้าขอปึกที่ 99 ก็ไม่โวยวาย แค่ให้จานว่างพร้อมบอกทางกลับ

ที่ไม่เหมือนกัน: คนจริงนับครั้งเดียวแล้วจำได้ · pagy ไม่จำอะไรข้ามคำขอเลย — ทุกครั้งที่ลูกค้าเปิดหน้าใหม่ มันไปนับใหม่ + หยิบใหม่จากตู้ (นั่นคือที่มาของ query COUNT ที่จะเห็นใน log ทุกหน้า)

สั่งติดตั้ง (ผลจริง):

$ bundle add pagy --version "~> 43.5"
Fetching gem metadata from https://rubygems.org/........
Resolving dependencies...

ดูสิ่งที่เปลี่ยน — ท้าย Gemfile มีสองบรรทัดใหม่ (บรรทัดว่าง + บรรทัดนี้):

gem "pagy", "~> 43.5"

และ Gemfile.lock (สมุดจดว่า "ติดตั้งรุ่นไหนไปจริง ๆ") ได้ pagy (43.8.1) กับ yaml (0.4.0) ที่ pagy ต้องใช้ · ถ้าของคุณได้ 43.x ที่ใหม่กว่า ไม่เป็นไร โค้ดในบทนี้ใช้ได้เหมือนกัน

⚠️ ติดตั้ง gem แล้วต้องปิดร้านแล้วเปิดใหม่ — ไปแท็บที่ 1 กด Ctrl+C แล้ว bin/dev อีกรอบ · Rails โหลด gem แค่ตอนเปิดร้าน · ถ้าไม่ restart ขั้นต่อไปจะเจอหน้าแดง NameError (uninitialized constant ApplicationController::Pagy) (ผลจริง)

🗣 ศัพท์ของขั้นนี้
gem · Gemfile · Gemfile.lock · bundle add
gem = แพ็กเกจของ Ruby · Gemfile = รายการ gem ที่โปรเจกต์ขอ · Gemfile.lock = รุ่นที่ติดตั้งจริง (commit ทั้งสองไฟล์เสมอ) · Bundler = ตัวจัดการ gem · ถาม senior ได้ว่า "ทีมนี้มี gem สำหรับ pagination ที่ใช้เป็นมาตรฐานไหม หรือให้ใช้ pagy"
pagination · limit · offset · paginator
pagination = แบ่งรายการเป็นหน้า · limit = จำนวนต่อหน้า · offset = ข้ามกี่แถว · paginator = ตัวที่คิดเรื่องพวกนี้ให้ · พิมพ์บอก AI ได้ว่า "paginate the posts index with pagy 43 (include Pagy::Method, limit: 10) — do not hand-roll offset"

ขั้น 2 — controller: หยิบทีละ 10 ชิ้น เรียงใหม่ไปเก่า พร้อมหมวด

ก่อนใช้ pagy(...) ใน controller ได้ ต้อง "สอน" controller ให้รู้จักมันก่อน · ทำที่ app/controllers/application_controller.rb (แม่ของทุก controller — ใส่ที่นี่ทีเดียว ทุกแผนกได้ใช้) · ไฟล์ทั้งไฟล์หลังแก้:

class ApplicationController < ActionController::Base
  include Pagy::Method

  # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  allow_browser versions: :modern

  # Changes to the importmap will invalidate the etag for HTML responses
  stale_when_importmap_changes
end

⚠️ tutorial บนเน็ตส่วนใหญ่เขียนสำหรับ pagy รุ่นเก่า — ถ้าเจอ include Pagy::Backend อย่าพิมพ์ตาม · รุ่น 43 ไม่มีแล้ว ผลจริงเมื่อลองใส่: หน้าแดง NameError (uninitialized constant Pagy::Backend) · ส่วน items: 10 (ชื่อเก่าของ limit:) แย่กว่า — ไม่ error เลย แค่เงียบ ๆ ได้หน้าละ 20 ชิ้น "หน้า 1 / 2" (ผลจริง)

ต่อไปที่ app/controllers/posts_controller.rb · ใน action index ของเดิมมีบรรทัดเดียวคือ @posts = Post.all · เปลี่ยนเป็นแบบนี้:

  # GET /posts or /posts.json
  def index
    @pagy, @posts = pagy(Post.includes(:category).recent, limit: 10)
  end

อ่านจากในออกนอก:

ในไฟล์เดียวกันเปลี่ยนข้อความแจ้งเตือนสามจุดเป็นภาษาไทยด้วย (ผู้ใช้เห็น) — เปลี่ยนแค่ข้อความในเครื่องหมายคำพูด:

        format.html { redirect_to @post, notice: "สร้างบทความแล้ว" }
…
        format.html { redirect_to @post, notice: "บันทึกการแก้ไขแล้ว", status: :see_other }
…
      format.html { redirect_to posts_path, notice: "ลบบทความแล้ว", status: :see_other }

(ของเดิมคือ "Post was successfully created." · "…updated." · "…destroyed." ใน action create · update · destroy)

รีเฟรช /posts — หน้าตายังเหมือนเดิม แต่เหลือ 10 ชิ้น · ดู log (ผลจริง ตัดบรรทัดประกอบที่ยังไม่ต้องดูออก — ขั้น 4 จะโชว์ log เต็มทั้งก้อน):

  Post Count (0.7ms)  SELECT COUNT(*) FROM "posts" /*…*/
  Post Exists? (0.8ms)  SELECT 1 AS one FROM "posts" LIMIT 1 OFFSET 0 /*…*/
  Post Load (0.8ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0 /*…*/
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1) /*…*/
Completed 200 OK in 50ms (Views: 23.2ms | ActiveRecord: 7.7ms (4 queries, 0 cached) | GC: 8.5ms)

AVOID eager loading detected
  Post => [:category]
  Remove from your query: .includes([:category])

ขั้น 3 — partial _post: แม่พิมพ์ที่ใช้ได้ทั้งหน้ารายการและหน้าเต็ม

เขียนทับ app/views/posts/_post.html.erb ทั้งไฟล์ · partial นี้จะถูกใช้สองแบบ: แบบย่อในหน้ารายการ (หัวข้อเป็นลิงก์ · ข้อความตัดสั้น) กับแบบเต็มในหน้า show (หัวข้อใหญ่ · ข้อความเต็ม) · สลับด้วยสวิตช์ full

<%# locals: (post:, full: false) -%>
<article id="<%= dom_id post %>" class="py-5 border-b border-gray-200">
  <p class="text-sm text-gray-500">
    <span class="font-medium text-blue-700"><%= post.category.name %></span>
    · <%= time_ago_in_words(post.created_at) %> ago
  </p>

  <% if full %>
    <h1 class="mt-2 text-3xl font-bold"><%= post.title %></h1>
    <p class="mt-4 leading-relaxed whitespace-pre-line"><%= post.description %></p>
  <% else %>
    <h2 class="mt-1 text-xl font-semibold">
      <%= link_to post.title, post, class: "hover:underline" %>
    </h2>
    <p class="mt-2 text-gray-700"><%= truncate(post.description, length: 80, separator: " ") %></p>
  <% end %>
</article>

ทีละบรรทัด:

ทำไมต้องมี separator: " " (ทวนบท 124) — ภาษาไทยมีสระกับวรรณยุกต์ที่ "ลอย" อยู่บนล่างตัวอักษร และนับเป็นตัวอักษรแยก · ถ้าตัดตรงกลางคำ อาจได้คำที่วรรณยุกต์หาย · ผลจริงกับ "ข้าวเหนียวมะม่วง" (bin/rails runner · ข้อความยาว 98 ตัวอักษร · … หน้าผลคือเราตัดส่วนต้นออกให้เห็นแค่ท้ายที่ถูกตัด):

description[0..80]                         → …โรยถั่วทองกรอบ ของหวานที่นัก
truncate(description, length: 80)          → …โรยถั่วทองกรอบ ของหวานที...
truncate(description, length: 80, separator: " ") → …โรยถั่วทองกรอบ...

แบบที่สอง (truncate เฉย ๆ) ได้ ที... — ไม้เอกของ "ที่" หายไป · truncate ดีกว่า [0..80] ตรงที่เติม ... ให้และรับ separator ได้ แต่ตัวมันไม่ได้รู้จักภาษาไทย · พอบอกให้ตัดที่ช่องว่าง (ภาษาไทยเว้นวรรคระหว่างวลีอยู่แล้ว) ก็ได้วลีครบ

🍽 เทียบให้เห็นภาพ — แม่พิมพ์ตักข้าวที่มีสวิตช์ "จานใหญ่"

แม่พิมพ์ _post คือแม่พิมพ์ตักข้าวอันเดียวที่ใช้ได้ทั้งจานเล็กในชุดรวม (หน้า index) และจานใหญ่สั่งเดี่ยว (หน้า show) · คนจัดจานยื่นของใส่มือแม่พิมพ์ชัด ๆ ว่า "นี่ข้าวของจานนี้ (post:) · ขอแบบจานใหญ่ (full: true)" · แม่พิมพ์ไม่ต้องรู้ว่าตัวเองอยู่ครัวไหน ถูกยกไปใช้ที่ไหนก็ได้

ที่ไม่เหมือนกัน: แม่พิมพ์จริงไม่มีทางเผลอเดินไปหยิบของเอง · แต่ partial เขียน @post ได้ — Rails ไม่ห้าม มันจะไปควานหาบนถาดหน้าครัวแล้ว "ใช้ได้แค่ครัวเดียว" (ดู predict ข้างล่าง) · strict locals ช่วยเรื่องของที่ต้องยื่น แต่ไม่ได้ห้ามการใช้ @ — จึงต้องตรวจด้วย grep ในขั้นตรวจรับ

เทียบกับ React: partial = component · locals = props (บท 71) · full: false = ค่าตั้งต้นของ prop แบบ function Post({ post, full = false }) · render @post, full: true ≈ <Post post={post} full /> · ต่างกันที่ React ไม่มีถาดกลางให้แอบหยิบ — component เห็นแค่ props ที่ส่งมา

รีเฟรช /posts — ชิ้นแรกเป็นแบบนี้ (HTML ที่เบราว์เซอร์ได้จริง จาก curl -s localhost:3000/posts):

<!-- BEGIN app/views/posts/_post.html.erb
--><article id="post_30" class="py-5 border-b border-gray-200">
  <p class="text-sm text-gray-500">
    <span class="font-medium text-blue-700">เครื่องดื่ม</span>
    · 1 day ago
  </p>

    <h2 class="mt-1 text-xl font-semibold">
      <a class="hover:underline" href="/posts/30">นมเย็น</a>
    </h2>
    <p class="mt-2 text-gray-700">นมสีชมพูจากน้ำแดง หวานหอม เด็ก ๆ ชอบที่สุด เติมไข่มุกได้</p>
</article>

log ตอนนี้: (4 queries, 0 cached) และคำเตือน AVOID ของ bullet หายไปแล้ว — เพราะเราใช้หมวดที่หยิบมาจริง

สมมติเผลอเขียนบรรทัด 4 ของ partial เป็น @post.category.name (แบบที่ tutorial หลายที่เขียน) แล้วเปิดสองหน้า:

http://localhost:3000/posts       ← หน้ารายการ
http://localhost:3000/posts/30    ← หน้าเต็ม

หน้าไหนพัง หน้าไหนรอด? (ตอนนี้ show.html.erb ยังเป็นของ scaffold ที่เรียก render @post)

ผลจริง: หน้ารายการพัง · หน้าเต็มรอด

GET /posts     → 500  ActionView::Template::Error (undefined method 'category' for nil)
GET /posts/30  → 200

หน้า show: controller วาง @post (ตัวเดียว) ไว้บนถาด → partial ไปควานเจอพอดี · หน้า index: controller วาง @posts (มี s) ไม่มี @post → ได้ nil แล้วถาม nil ว่า "หมวดของคุณคืออะไร" → พัง (ActionView::Template::Error = พังระหว่างจัดจานใน view)
อันตรายตรงที่ถ้าคุณทดสอบแค่หน้า show จะคิดว่าใช้ได้ · นี่คือเหตุผลที่ prompt ห้าม "แตะ @post ข้างใน partial" · ลองแล้วแก้กลับเป็น post.category.name ก่อนไปต่อ

🗣 ศัพท์ของขั้นนี้
strict locals · local variable vs instance variable
strict locals = บรรทัด <%# locals: (...) %> ที่ประกาศว่า partial รับอะไร · local = ของที่ยื่นใส่มือ (post) · instance variable = ของบนถาดหน้าครัว (@post) · พิมพ์บอก AI ได้ว่า "partials must only use locals declared with strict locals; never reference instance variables inside a partial"
truncate · separator · grapheme
truncate ตัดตามจำนวนตัวอักษร · separator บอกให้ตัดที่ตัวคั่น · grapheme = "ตัวที่ตาเห็นเป็นหนึ่งตัว" เช่น "ที่" อาจประกอบจากหลายตัวอักษรในคอมพิวเตอร์ · ถาม senior ได้ว่า "ข้อความไทยที่ตัดสั้น ทีมเราใช้ separator: ' ' หรือมี helper กลางอยู่แล้ว"

ขั้น 4 — หน้า index กับแถบเปลี่ยนหน้า

เขียนทับ app/views/posts/index.html.erb ทั้งไฟล์:

<% content_for :title, "บทความทั้งหมด" %>

<div class="w-full max-w-3xl mx-auto">
  <% if notice.present? %>
    <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block" id="notice"><%= notice %></p>
  <% end %>

  <div class="flex justify-between items-center gap-4">
    <h1 class="font-bold text-3xl sm:text-4xl">บทความทั้งหมด</h1>
    <%= link_to "เขียนบทความใหม่", new_post_path, class: "shrink-0 rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium" %>
  </div>

  <div id="posts">
    <%= render(@posts) || tag.p("ไม่พบบทความ", class: "text-center text-gray-500 my-10") %>
  </div>

  <%= render "shared/pagination", pagy: @pagy %>
</div>

ทำไมไม่เขียน if @posts.any? แบบ scaffold: ขั้น 2 เห็นแล้วว่า any? ทำให้มี Post Exists? เพิ่ม 1 ครั้ง → 4 query เกินเกณฑ์ "ไม่เกิน 3" (เกณฑ์ตรวจรับของสไลด์ · เจอเต็ม ๆ ในขั้น 7) · ส่วน render(...) || ไม่ต้องถามตู้เพิ่ม เพราะรู้ว่าว่างจากของที่หยิบมาแล้ว

ต่อไปสร้างโฟลเดอร์ app/views/shared/ (ยังไม่มี · mkdir app/views/shared หรือให้ editor สร้าง) แล้วสร้าง app/views/shared/_pagination.html.erb:

<%# locals: (pagy:) -%>
<nav class="mt-8 flex items-center justify-between gap-2 text-sm" aria-label="เปลี่ยนหน้า">
  <% if pagy.previous %>
    <%= link_to "← ก่อนหน้า", pagy.page_url(:previous), class: "rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" %>
  <% else %>
    <span class="rounded-md px-3 py-2 text-gray-400">← ก่อนหน้า</span>
  <% end %>

  <span class="text-gray-600">หน้า <%= pagy.page %> / <%= pagy.last %></span>

  <% if pagy.next %>
    <%= link_to "ถัดไป →", pagy.page_url(:next), class: "rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" %>
  <% else %>
    <span class="rounded-md px-3 py-2 text-gray-400">ถัดไป →</span>
  <% end %>
</nav>

⚠️ ทำไมไม่ใช้แถบสำเร็จรูปของ pagy (series_nav) — มันคืน HTML เป็นข้อความธรรมดา · ใส่ใน <%= %> จะถูกใส่ซองใสจนโชว์เป็นตัวหนังสือ <nav class="pagy series-nav"… บนหน้าเว็บ (ผลจริง) · คู่มือของ pagy เองจึงเขียน <%== @pagy.series_nav %> — <%== คือตัวย่อของ raw = แกะซองออก ซึ่งขัดกับข้อห้ามใน prompt · เราจึงเขียนแถบเองด้วย link_to 16 บรรทัด

รีเฟรช /posts · แถบท้ายหน้าที่เบราว์เซอร์ได้จริง (จาก curl -s localhost:3000/posts):

<nav class="mt-8 flex items-center justify-between gap-2 text-sm" aria-label="เปลี่ยนหน้า">
    <span class="rounded-md px-3 py-2 text-gray-400">← ก่อนหน้า</span>

  <span class="text-gray-600">หน้า 1 / 3</span>

    <a class="rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" href="/posts?page=2">ถัดไป →</a>
</nav>

หน้า 1 → "ก่อนหน้า" เป็น <span> สีเทา (มาจากบรรทัด 6) · "ถัดไป" เป็นลิงก์ /posts?page=2 (บรรทัด 12) · ส่วน log ของหน้านี้ (ผลจริง ครบทั้งก้อน):

Started GET "/posts" for ::1 at 2026-11-07 10:38:12 +0700
Processing by PostsController#index as */*
  Post Count (0.9ms)  SELECT COUNT(*) FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/controllers/posts_controller.rb:6:in 'PostsController#index'
  Rendering layout layouts/application.html.erb
  Rendering posts/index.html.erb within layouts/application
  Post Load (0.7ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:15
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1) /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
  ↳ app/views/posts/index.html.erb:15
  Rendered collection of posts/_post.html.erb [10 times] (Duration: 1.1ms | GC: 0.1ms)
  Rendered shared/_pagination.html.erb (Duration: 0.0ms | GC: 0.0ms)
  Rendered posts/index.html.erb within layouts/application (Duration: 4.4ms | GC: 0.2ms)
  Rendered layout layouts/application.html.erb (Duration: 4.6ms | GC: 0.2ms)
Completed 200 OK in 6ms (Views: 4.0ms | ActiveRecord: 1.3ms (3 queries, 0 cached) | GC: 0.2ms)

⚠️ ถ้าหน้าดูเหมือนไม่มี style เลย (ไม่มีระยะห่าง ไม่มีสี) แปลว่าคุณเปิดร้านด้วย bin/rails server เฉย ๆ แทน bin/dev — คลาส Tailwind ใหม่ ๆ ที่เพิ่งพิมพ์ (max-w-3xl · text-3xl …) ยังไม่ถูกสร้างลงไฟล์ CSS · สั่ง bin/rails tailwindcss:build หรือเปลี่ยนไปใช้ bin/dev (เหตุผลอยู่ในบท 128)

ขั้น 5 — หน้า show พร้อมทางกลับ (breadcrumb)

breadcrumb คือแถบเล็ก ๆ บนหัวหน้าที่บอกว่า "ตอนนี้อยู่ตรงไหน และกลับไปทางไหนได้" เช่น บทความทั้งหมด › เครื่องดื่ม › นมเย็น · ชื่อมาจากนิทานที่โปรยเศษขนมปังไว้หาทางกลับบ้าน · เขียนทับ app/views/posts/show.html.erb ทั้งไฟล์:

<% content_for :title, @post.title %>

<div class="w-full max-w-3xl mx-auto">
  <% if notice.present? %>
    <p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block" id="notice"><%= notice %></p>
  <% end %>

  <nav class="text-sm text-gray-500" aria-label="breadcrumb">
    <%= link_to "บทความทั้งหมด", posts_path, class: "hover:underline" %>
    ›
    <%= link_to @post.category.name, @post.category, class: "hover:underline" %>
    ›
    <span class="text-gray-800"><%= @post.title %></span>
  </nav>

  <%= render @post, full: true %>

  <div class="mt-6 flex flex-col sm:flex-row gap-2">
    <%= link_to "แก้ไข", edit_post_path(@post), class: "text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-200 font-medium" %>
    <%= link_to "กลับไปหน้ารวม", posts_path, class: "text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-200 font-medium" %>
    <%= button_to "ลบบทความ", @post, method: :delete, class: "w-full rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer", data: { turbo_confirm: "ลบบทความนี้จริงไหม?" } %>
  </div>
</div>

เปิด /posts/30 · breadcrumb กับตัวบทความที่เบราว์เซอร์ได้จริง (curl -s localhost:3000/posts/30):

  <nav class="text-sm text-gray-500" aria-label="breadcrumb">
    <a class="hover:underline" href="/posts">บทความทั้งหมด</a>
    ›
    <a class="hover:underline" href="/categories/5">เครื่องดื่ม</a>
    ›
    <span class="text-gray-800">นมเย็น</span>
  </nav>

  <!-- BEGIN app/views/posts/_post.html.erb
--><article id="post_30" class="py-5 border-b border-gray-200">
  <p class="text-sm text-gray-500">
    <span class="font-medium text-blue-700">เครื่องดื่ม</span>
    · 1 day ago
  </p>

    <h1 class="mt-2 text-3xl font-bold">นมเย็น</h1>
    <p class="mt-4 leading-relaxed whitespace-pre-line">นมสีชมพูจากน้ำแดง หวานหอม เด็ก ๆ ชอบที่สุด เติมไข่มุกได้</p>
</article>

ครึ่งล่างมาจากแม่พิมพ์ _post ตัวเดียวกับหน้ารายการ แต่เข้าฝั่ง if full → ได้ <h1> กับข้อความเต็ม · log ของหน้านี้ (ผลจริง): (2 queries, 0 cached) = หยิบบทความ 1 แผ่น + หยิบหมวด 1 แผ่น · ลิงก์ เครื่องดื่ม พาไปหน้าหมวดของ scaffold (ยังเป็นภาษาอังกฤษ ไม่ได้แตะในบทนี้)

🍽 เทียบให้เห็นภาพ — ป้ายบอกทางในศูนย์อาหาร

เดินเข้าศูนย์อาหารชั้น 5 จะมีป้าย "ศูนย์อาหาร › โซนเครื่องดื่ม › ร้านนมเย็น" ให้รู้ว่าอยู่ตรงไหน และอยากกลับไปดูทั้งโซนก็เดินตามป้ายได้ทันที · breadcrumb คือป้ายนั้นบนหน้าเว็บ — คนที่เข้ามาจาก Google ตรงไปหน้าบทความเลย ก็ยังรู้ว่ามีหมวดเครื่องดื่มทั้งหมวดให้ดูต่อ

ที่ไม่เหมือนกัน: ป้ายในห้างติดตายอยู่กับที่ · breadcrumb ถูกสร้างใหม่ทุกครั้งจากข้อมูลของบทความ — ย้ายบทความไปหมวดอื่น ป้ายก็เปลี่ยนตามเอง · และมันไม่ได้จำว่าคุณเดินมาทางไหน: ต่อให้มาจากหน้า 3 ของรายการ ปุ่ม "บทความทั้งหมด" ก็พากลับหน้า 1

🗣 ศัพท์ของขั้นนี้
breadcrumb · aria-label
breadcrumb = แถบบอกตำแหน่ง "หน้าแรก › หมวด › หน้านี้" · aria-label = ชื่อของส่วนนั้นสำหรับโปรแกรมอ่านหน้าจอ · พิมพ์บอก AI ได้ว่า "add a breadcrumb to posts#show: All posts › category name (linked) › post title, using link_to with route helpers"
render with locals · full: true
ส่งของเพิ่มให้ partial ตอนเรียก render @post, full: true · ใน React คือ prop แบบ boolean · ถาม senior ได้ว่า "partial ตัวเดียวสองโหมดแบบนี้ ทีมเราโอเคไหม หรือแยกเป็นสองไฟล์ดีกว่า"

ขั้น 6 — ฟอร์ม: เลือกหมวดจากรายการ แทนพิมพ์เลข

ฟอร์มของ scaffold ให้ พิมพ์เลข category_id เอง — ผู้ใช้จะรู้ได้ยังไงว่าหมวดของหวานคือเลข 3 · เปลี่ยนเป็นกล่องเลือก (dropdown) ด้วย collection_select (บท 124) · ไฟล์ app/views/posts/_form.html.erb ทั้งไฟล์หลังแก้:

<%= form_with(model: post, class: "contents") do |form| %>
  <% if post.errors.any? %>
    <div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-md mt-3">
      <h2>บันทึกไม่ได้ มี <%= post.errors.count %> จุดที่ต้องแก้:</h2>

      <ul class="list-disc ml-6">
        <% post.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-5">
    <%= form.label :title, "หัวข้อ" %>
    <%= form.text_field :title, class: ["block shadow-sm rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-400 focus:outline-blue-600": post.errors[:title].none?, "border-red-400 focus:outline-red-600": post.errors[:title].any?}] %>
  </div>

  <div class="my-5">
    <%= form.label :description, "เนื้อหา" %>
    <%= form.textarea :description, rows: 4, class: ["block shadow-sm rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-400 focus:outline-blue-600": post.errors[:description].none?, "border-red-400 focus:outline-red-600": post.errors[:description].any?}] %>
  </div>

  <div class="my-5">
    <%= form.label :category_id, "หมวด" %>
    <%= form.collection_select :category_id, Category.all, :id, :name, { prompt: "เลือกหมวด" }, class: ["block shadow-sm rounded-md border px-3 py-2 mt-2 w-full", {"border-gray-400 focus:outline-blue-600": post.errors[:category].none?, "border-red-400 focus:outline-red-600": post.errors[:category].any?}] %>
  </div>

  <div class="inline">
    <%= form.submit "บันทึก", class: "w-full sm:w-auto rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
  </div>
<% end %>

เทียบกับของเดิม จุดที่เปลี่ยน (ที่เหลือเป็นของ scaffold ทั้งหมด):

ก้อน class: [ "…", { "…": เงื่อนไข } ] ยาว ๆ ที่ไม่ได้แตะ อ่านยังไง — เป็นของ scaffold · ช่องแรกคือคลาสที่ใช้เสมอ · ในปีกกาคือ "คลาสนี้ : ใส่เมื่อเงื่อนไขเป็นจริง" · ช่องไม่มี error (.none?) ได้ขอบเทา · มี error (.any?) ได้ขอบแดง · ดูผลได้ใน HTML ข้างล่าง: ฟอร์มเปล่ายังไม่มี error จึงได้ border-gray-400 focus:outline-blue-600 ต่อท้าย

เปิด /posts/new · กล่องเลือกที่เบราว์เซอร์ได้จริง (curl -s localhost:3000/posts/new):

    <label for="post_category_id">หมวด</label>
    <select class="block shadow-sm rounded-md border px-3 py-2 mt-2 w-full border-gray-400 focus:outline-blue-600" name="post[category_id]" id="post_category_id"><option value="">เลือกหมวด</option>
<option value="1">อาหารไทย</option>
<option value="2">ก๋วยเตี๋ยว</option>
<option value="3">ของหวาน</option>
<option value="4">อาหารริมทาง</option>
<option value="5">เครื่องดื่ม</option></select>

ลองกด บันทึก โดยไม่กรอกอะไรเลย (ตรวจด้วย Chrome จริง): ได้กล่องแดง บันทึกไม่ได้ มี 3 จุดที่ต้องแก้: Category must exist · Title can't be blank · Description can't be blank และกล่องเลือกหมวดขอบแดง · ข้อความ error ยังเป็นภาษาอังกฤษเพราะมาจาก Rails (ไม่ได้ตั้งภาษาไทย) · ใน Console ของเบราว์เซอร์ (คลิกขวา → Inspect → แท็บ Console) จะมี 422 (Unprocessable Content) — ปกติของฟอร์มที่ไม่ผ่าน validation ไม่ใช่บั๊ก

สุดท้ายของขั้นนี้ แปลข้อความในหน้า new กับ edit · app/views/posts/new.html.erb ทั้งไฟล์:

<% content_for :title, "เขียนบทความใหม่" %>

<div class="md:w-2/3 w-full">
  <h1 class="font-bold text-4xl">เขียนบทความใหม่</h1>

  <%= render "form", post: @post %>

  <%= link_to "กลับไปหน้ารวม", posts_path, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %>
</div>

app/views/posts/edit.html.erb ทั้งไฟล์:

<% content_for :title, "แก้ไขบทความ" %>

<div class="md:w-2/3 w-full">
  <h1 class="font-bold text-4xl">แก้ไขบทความ</h1>

  <%= render "form", post: @post %>

  <%= link_to "ดูบทความนี้", @post, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %>
  <%= link_to "กลับไปหน้ารวม", posts_path, class: "w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium" %>
</div>

เปลี่ยนแค่ข้อความ · class เดิมทุกตัว · render "form", post: @post = ยื่น @post ใส่มือแม่พิมพ์ _form ในชื่อ post (นี่คือเหตุผลที่ใน _form เขียน post.errors ไม่ใช่ @post.errors)

ขั้น 7 — เวลาไทย และไฟล์ prompt ของ Lab

ใน config/application.rb (ไฟล์ตั้งค่าของทั้งแอป) มีบรรทัดที่ถูกปิดไว้ด้วย # · แก้บรรทัดนี้:

    # config.time_zone = "Central Time (US & Canada)"

เป็น:

    config.time_zone = "Asia/Bangkok"

ปิดแล้วเปิดร้านใหม่ (ไฟล์ใน config/ ถูกอ่านแค่ตอนเปิดร้าน) · อยากเห็นผลต่างเอง ให้ถามเวลาสร้างของ "นมเย็น" ด้วย bin/rails runner (รันโค้ด Ruby หนึ่งบรรทัดในแอป) ทั้งก่อนและหลังแก้ · ผลจริง:

$ bin/rails runner 'p Time.zone.name; p Post.find_by!(title: "นมเย็น").created_at'
# ก่อนแก้
"UTC"
2026-11-06 04:14:55.753375000 UTC +00:00
# หลังแก้
"Asia/Bangkok"
2026-11-06 11:14:55.753375000 +07 +07:00

เวลาเดียวกัน แต่แสดงเป็นเวลาไทย (+7 ชั่วโมง) · ในตู้ยังเก็บเป็น UTC เหมือนเดิม · จะเห็นผลเวลาเปิด /posts.json หรือเมื่อโชว์วันที่ (บท 124 l) · time_ago_in_words ไม่เปลี่ยน เพราะมันนับแค่ส่วนต่าง

ต่อไปคือ prompt ของ Lab · สร้างไฟล์ prompts/01-views.md ตามสไลด์หน้า 21 (ทั้งไฟล์ ข้างใน prompts/ ที่มี 00-context.md อยู่แล้ว · บท 121):

## เป้าหมาย

หน้า index กับ show ของ Post ที่คนอ่านรู้เรื่อง
ใช้ partial ร่วมกัน และมีข้อมูลตัวอย่างพอให้เห็นปัญหา

## ข้อกำหนด

- partial app/views/posts/_post.html.erb
  รับค่าผ่าน local ห้ามใช้ instance variable
- index ใช้ render @posts ไม่ต้องเขียน each เอง
- โชว์ชื่อหมวด ไม่ใช่ category_id
- ตัดคำอธิบายด้วย truncate ไม่ใช่ substring
- แสดงเวลาด้วย time_ago_in_words
- index ต้องมี pagination หน้าละ 10
- controller ต้อง includes(:category)
- show มี breadcrumb กลับไปหน้า index
- db/seeds.rb สร้าง 5 หมวด 30 โพสต์ ข้อความไทย

## ห้าม

- ใช้ raw หรือ html_safe กับข้อมูลจากผู้ใช้
- Post.all ที่ไม่มี limit
- เขียน path เป็น string เช่น "/posts/#{id}"
- แตะ @post ข้างใน partial

## เกณฑ์ตรวจรับ

- [ ] เปิด /posts แล้วเห็นรายการพร้อมชื่อหมวด
- [ ] log โชว์ไม่เกิน 3 query ต่อการโหลด 1 หน้า
- [ ] partial ไม่มีคำว่า @ อยู่เลย
- [ ] grep หาคำว่า raw กับ html_safe แล้วไม่เจอ
- [ ] มี pagination ใช้งานได้จริง
- [ ] seed รันซ้ำได้ไม่พัง

สไลด์หน้า 21 ชี้ว่า ทุกบรรทัดในข้อกำหนดกับข้อห้ามมาจากสิ่งที่เพิ่งเรียน — ถ้าเมื่อเช้าให้เขียน prompt นี้ เราจะเขียนได้แค่บรรทัดเป้าหมาย · ลองจับคู่ดูว่าแต่ละบรรทัดมาจากไหน และใน Lab นี้เราทำตรงไหน:

บรรทัดใน promptเรียนที่ใน Lab นี้
partial รับค่าผ่าน local · แตะ @post ข้างใน partial (ห้าม)บท 123ขั้น 3 (strict locals · predict)
index ใช้ render @postsบท 123ขั้น 4
โชว์ชื่อหมวด · controller ต้อง includesบท 114 · บท 125ขั้น 2–3
truncate ไม่ใช่ substring · time_ago_in_wordsบท 124ขั้น 3
pagination หน้าละ 10 · Post.all ที่ไม่มี limit (ห้าม)กฎข้อ 6 ใน 00-context (บท 121)ขั้น 1–2 · 4
breadcrumbบทนี้ขั้น 5
path เป็น string (ห้าม)บท 101 (ชื่อ route _path)ทุกขั้นใช้ posts_path · link_to …, post
raw / html_safe (ห้าม)บท 125ขั้น 4 (ไม่ใช้ series_nav)
seeds 5 หมวด 30 โพสต์บท 120มีอยู่แล้ว · ตรวจในขั้นตรวจรับ

แก้ prompts/00-context.md อีกหนึ่งบรรทัด (ในหัวข้อ "รูปแบบโค้ด") · บรรทัดเดิม:

- ข้อความที่ผู้ใช้เห็นเป็นภาษาไทย ผ่าน i18n

เปลี่ยนเป็น:

- ข้อความที่ผู้ใช้เห็นเป็นภาษาไทย เขียนตรง ๆ ใน view (ยังไม่ใช้ i18n)

ทำไม: i18n คือระบบแยกข้อความไปเก็บในไฟล์ภาษาแล้วเรียกด้วย t(".title") · โปรเจกต์ของเรายังไม่ได้ตั้งระบบนั้น (เขียนภาษาไทยตรง ๆ ใน view อย่างที่ทำมาทั้ง Lab) · ถ้าปล่อยบรรทัดเดิมไว้ AI ที่อ่าน context จะเขียน t(".title") มาให้ แล้วหน้าเว็บโชว์ข้อความแปลก ๆ แทนภาษาไทย — context ที่ไม่ตรงกับโค้ดจริงทำให้ AI เข้าใจผิด

🍽 เทียบให้เห็นภาพ — ใบสั่งงานของกะนี้ กับแฟ้มคู่มือพนักงานใหม่

00-context.md คือแฟ้มคู่มือพนักงานใหม่ที่แนบไปทุกกะ (กฎถาวรของร้าน) · 01-views.md คือใบสั่งงานของกะนี้ ที่เขียนว่าต้องทำอะไร · ห้ามอะไร · และ "ตอนส่งงาน หัวหน้าจะตรวจ 6 ข้อนี้" · พนักงานใหม่ (AI) ที่ได้ทั้งสองแผ่นจะทำงานได้ใกล้เคียงคนเก่า · ถ้าคู่มือเขียนผิด (บอกให้ใช้ระบบที่ร้านไม่มี) พนักงานก็ทำผิดตามคู่มืออย่างตั้งใจ

ที่ไม่เหมือนกัน: พนักงานคนจริงทำผิดครั้งหนึ่งแล้วจำได้ · AI ไม่จำข้ามครั้ง — สิ่งเดียวที่ทำให้มันไม่พลาดซ้ำคือเราเขียนลงแฟ้ม (ภารกิจท้ายบท) · และพนักงานคนจริงบอกได้ว่า "ไม่แน่ใจ" แต่ AI มักส่งงานที่ดูครบเสมอ — ใบตรวจรับจึงสำคัญกว่าใบสั่งงาน

🗣 ศัพท์ของขั้นนี้
acceptance criteria · checklist ตรวจรับ
รายการที่บอกว่า "งานแบบไหนถึงเรียกว่าเสร็จ" ตรวจได้ด้วยคำสั่งหรือการกระทำที่ชัด ไม่ใช่ความรู้สึก · พิมพ์บอก AI ได้ว่า "before you say you're done, run each acceptance check in prompts/01-views.md and paste the output"
time zone · i18n
time zone = เขตเวลาที่แอปใช้แสดงผล · i18n (internationalization — i กับ n มีตัวอักษรคั่น 18 ตัว) = ระบบแยกข้อความหลายภาษา · ถาม senior ได้ว่า "โปรเจกต์นี้จะทำ i18n ไหม ถ้าทำ เริ่มตั้งแต่ตอนนี้จะถูกกว่าย้ายทีหลังไหม"

ตรวจรับตาม checklist ของสไลด์หน้า 21 — ทีละข้อ

ถึงตรงนี้โค้ดครบแล้ว · ตรวจรับเหมือนเป็นงานที่คนอื่น (หรือ AI) ส่งมา — ทุกข้อมีคำสั่งตรวจจริงและผลจริง:

เกณฑ์ตรวจด้วยผลจริง
เห็นรายการพร้อมชื่อหมวดเปิด /posts10 ชิ้น · ชิ้นแรก เครื่องดื่ม · 1 day ago / นมเย็น
ไม่เกิน 3 query ต่อหน้าtail -f log/development.log (ตามสไลด์หน้า 22 · ให้ log ไหลขึ้นจอเรื่อย ๆ · กด Ctrl+C เพื่อหยุด) แล้วดู Completed … ของทุกหน้า/posts · ?page=2 · ?page=3 = 3 · /posts/30 = 2 · ?page=99 = 2
partial ไม่มี @grep -n "@" app/views/posts/_*.html.erb app/views/shared/_*.html.erbไม่มี output (ไม่เจอ)
ไม่มี raw / html_safegrep -rn "raw\|html_safe\|<%==" app/viewsไม่มี output
pagination ใช้ได้จริงกด "ถัดไป →" · ลองพิมพ์ ?page= แปลก ๆดูตารางและ predict ข้างล่าง
seed รันซ้ำไม่พังbin/rails db:seed สองรอบสองรอบได้ categories: 5 · posts: 30 เท่ากัน

grep = คำสั่งค้นหาข้อความในไฟล์ · -n บอกเลขบรรทัด · -r ค้นทั้งโฟลเดอร์ · \| = "หรือ" · _*.html.erb = ทุกไฟล์ที่ขึ้นต้นด้วย _ (คือ partial ทั้งหมด) · ไม่มี output = ไม่เจอ = ผ่าน

⚠️ คำสั่ง grep ของสไลด์หน้า 22 ("raw\|html_safe") จับ <%== ไม่ได้ — ผมลองเติมบรรทัด <%== @pagy.series_nav %> ต่อท้ายไฟล์ index แล้วรัน grep ของสไลด์: ไม่มี output (ผ่านทั้งที่มีของแกะซองอยู่) · เติม \|<%== ต่อท้ายแบบในตาราง แล้วเจอทันที: app/views/posts/index.html.erb:19:<%== @pagy.series_nav %> (ลองแล้วลบบรรทัดนั้นออก)

pagination: ลองค่าแปลก ๆ

นี่คือเหตุผลที่ไม่เขียนเอง — ผลจริงจาก curl ทีละ URL (ถ้าคุณเคยทำ Go บท 35 ที่ต้องเขียน binding:"min=1" เองแล้วได้ 400 · pagy เลือกอีกทาง):

URLบทความแถบเปลี่ยนหน้าquery
/posts10หน้า 1 / 3 · ถัดไป → ?page=23
/posts?page=210หน้า 2 / 3 · ก่อนหน้า ?page=1 · ถัดไป ?page=33
/posts?page=310หน้า 3 / 3 · ถัดไปเป็นสีเทา3
/posts?page=0 · ?page=abc10หน้า 1 / 3 (pagy ถือว่าเป็นหน้า 1 ไม่ error)3

ใน Chrome จริง: กด "ถัดไป →" แล้ว URL เปลี่ยนเป็น /posts?page=2 หัวข้อแรกเป็น ชามะนาว · และจอกว้าง 375px (ขนาดมือถือ) ทุกหน้าไม่มีแถบเลื่อนแนวนอน

http://localhost:3000/posts?page=99

มีแค่ 3 หน้า แต่ขอหน้า 99 — ได้อะไร? หน้าแดง error · ถูกพากลับหน้า 3 · หรืออย่างอื่น? และแถบเปลี่ยนหน้าเขียนว่าอะไร

ผลจริง: ได้ 200 หน้าว่าง (ไม่ error · ไม่พากลับ) · HTML ส่วนกลาง (curl -s "localhost:3000/posts?page=99"):

  <div id="posts">
    <p class="text-center text-gray-500 my-10">ไม่พบบทความ</p>
  </div>

  <!-- BEGIN app/views/shared/_pagination.html.erb
--><nav class="mt-8 flex items-center justify-between gap-2 text-sm" aria-label="เปลี่ยนหน้า">
    <a class="rounded-md px-3 py-2 bg-gray-100 hover:bg-gray-200 font-medium" href="/posts?page=3">← ก่อนหน้า</a>

  <span class="text-gray-600">หน้า 99 / 3</span>

    <span class="rounded-md px-3 py-2 text-gray-400">ถัดไป →</span>
</nav>
  • ไม่พบบทความ ← render(@posts) คืน nil เพราะไม่มีชิ้นไหน → || ไปทางขวา (ขั้น 4 บรรทัด 14 ทำงานจริง)
  • หน้า 99 / 3 ดูแปลกแต่ซื่อตรง · ปุ่ม "← ก่อนหน้า" ชี้ไปหน้า 3 (หน้าสุดท้ายที่มีของ) = ทางกลับ
  • log: (2 queries, 0 cached) — ไม่มีบทความ จึงไม่ต้องไปหยิบหมวด
  • รุ่น 43 ให้หน้าว่างเป็นค่าตั้งต้น · ถ้าอยากให้เป็น error ต้องสั่งเพิ่มเอง (raise_range_error: true ในโค้ดของ pagy) — Lab นี้ไม่ใช้

ใส่บั๊กกลับ เพื่อให้เชื่อตัวตรวจได้

"log ได้ 3 query" ผ่านแล้ว — แต่ถ้าตัวตรวจไม่เคยจับบั๊กได้เลย เราจะรู้ได้ไงว่ามันทำงาน · ลองลบ .includes(:category) ออกชั่วคราว ให้เหลือ pagy(Post.recent, limit: 10) แล้วรีเฟรช /posts · log จริง (ตัดบรรทัด ↳ กับ Rendering ออก · ย่อคอมเมนต์ท้าย SQL · ตัดบรรทัด user: กับ Call stack ของ bullet ออก):

  Post Count (0.6ms)  SELECT COUNT(*) FROM "posts" /*…*/
  Post Load (0.7ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0 /*…*/
  Category Load (0.6ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 5 LIMIT 1 /*…*/
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 4 LIMIT 1 /*…*/
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 3 LIMIT 1 /*…*/
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1 /*…*/
  Category Load (0.7ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1 /*…*/
  CACHE Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 5 LIMIT 1
  CACHE Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 4 LIMIT 1
  CACHE Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 3 LIMIT 1
  CACHE Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1
  CACHE Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1
Completed 200 OK in 21ms (Views: 14.0ms | ActiveRecord: 4.5ms (12 queries, 5 cached) | GC: 0.0ms)

USE eager loading detected
  Post => [:category]
  Add to your query: .includes([:category])

ใส่ .includes(:category) กลับ แล้วรีเฟรช → กลับมา 3 queries · ก่อน commit ต้องอยู่ในสถานะนี้

seed รันซ้ำไม่พัง

$ bin/rails db:seed
categories: 5 · posts: 30
$ bin/rails db:seed
categories: 5 · posts: 30

รอบสองตัวเลขไม่เพิ่ม เพราะ seeds ของบท 120 ใช้ find_or_create_by! ("หาแฟ้มนี้ก่อน มีแล้วใช้ของเดิม ไม่มีค่อยสร้าง") · นี่คือประโยค ② ของวันในรูปเล็ก ๆ: ทำซ้ำได้โดยไม่เบิ้ล · seed ที่ใช้ create! ตรง ๆ รอบสองจะได้ 60 บทความ

ที่ AI พลาดประจำในบล็อกนี้ — อาการ และตรวจยังไง

สไลด์หน้า 22 รวบรวมไว้ 5 ข้อ · ผมเติม 2 ข้อท้ายที่เจอเองตอนทำ Lab นี้ (pagy เปลี่ยน API บ่อย AI มักเขียนตามรุ่นเก่า):

AI ทำแบบนี้อาการที่เห็นตรวจยังไง (≤ 30 วินาที)
ลืม includes(:category)หน้าเปิดได้ปกติ แค่ช้าลงเมื่อข้อมูลเยอะlog: (12 queries, 5 cached) · bullet USE eager loading
ใช้ description[0..80] แทน truncateตัดกลางคำ ไม่มี ...grep -rn "\[0\.\." app/views (\ หน้า [ และ . = ให้หาตัวอักษรนั้นตรง ๆ) · และดูว่า truncate มี separator: ไหม (ไม่มี = วรรณยุกต์หายได้)
ทำ pagination เองด้วย offsetมักลืมกรณี ?page=0 · ?page=abc · หน้าเกิน → อาจได้หน้าแดงหรือผลแปลกgrep -rn "offset" app/controllers · ลอง URL แปลก ๆ ในตารางด้านบน
ใส่ @post ใน partialหน้า show ใช้ได้ หน้า index พัง undefined method … for nilgrep -n "@" app/views/posts/_*.html.erb
seed ใช้ create! แทน find_or_create_by!รันรอบสอง ข้อมูลเบิ้ล (60 ชิ้น)รัน bin/rails db:seed สองรอบ ดูตัวเลข
ใช้ API ของ pagy รุ่นเก่าPagy::Backend → หน้าแดง NameError · items: 10 → เงียบ ๆ ได้หน้าละ 20นับบทความบนหน้า · grep -rn "Pagy::Backend\|items:" app/controllers (ค้นทั้ง app ไม่ได้ — ไปเจอ align-items: ในไฟล์ CSS ที่ build แล้ว)
ใช้ <%== @pagy.series_nav %> ตามคู่มือ pagyใช้ได้สวยงาม แต่เป็นการแกะซอง (= raw)grep -rn "raw\|html_safe\|<%==" app/views

สังเกตว่าเกือบทุกข้อ "ใช้ได้" ตอนเปิดดูด้วยตา — เปิดหน้าแล้วสวยไม่ได้แปลว่าผ่าน · checklist ที่รันเป็นคำสั่งได้จึงสำคัญ

ทางเลือก: ลองให้ AI ทำ Lab เดียวกัน แล้วตรวจด้วย checklist ชุดนี้

ทำหลัง commit ของ Lab แล้ว (ส่วนถัดไป) · ไม่บังคับ · ขั้นตอน:

  1. git switch -c ai-vibe1 HEAD~1 — สร้าง branch ใหม่จาก commit ก่อน Lab (= สถานะตอนจบบท 120) แล้วย้ายไปอยู่ที่นั่น · โค้ดใน editor จะกลับเป็น scaffold ดิบ (HEAD~1 = "ถอยไป 1 commit" · ถ้า commit ภารกิจท้ายบทไปแล้วด้วย ใช้ HEAD~2)
  2. git checkout @{-1} -- prompts/ — ก็อปโฟลเดอร์ prompts/ จาก branch ที่เพิ่งจากมา (@{-1} = "branch ก่อนหน้า") · AI จะได้มีใบสั่งงานกับคู่มือ
  3. เปิด Claude Code (บท 86) แล้วสั่งประมาณ: "อ่าน @prompts/00-context.md และ @prompts/01-views.md แล้วทำตาม · เสร็จแล้วรันเกณฑ์ตรวจรับทุกข้อให้ดู"
  4. ตรวจเองด้วยตาราง checklist และตาราง "AI พลาดประจำ" ด้านบน — อย่าเชื่อที่ AI บอกว่าผ่าน รันคำสั่งเอง
  5. กลับบ้าน: git add . && git commit -m "ลอง AI" แล้ว git switch - (กลับ branch เดิม) · ปิดเปิด bin/dev ใหม่ · ถ้า AI แก้ seeds หรือสร้าง migration ข้อมูลในตู้จะเปลี่ยนตาม → bin/rails db:reset (ล้างตู้แล้วสร้างใหม่จาก schema + seeds ของ branch ที่อยู่)

เทียบผลกับของที่คุณทำเอง · ข้อที่ AI พลาด = วัตถุดิบของภารกิจท้ายบท

เก็บเข้าโปรเจกต์

ดูก่อนว่าจะเก็บอะไรบ้าง (ผลจริง):

$ git status --short
 M Gemfile
 M Gemfile.lock
 M app/controllers/application_controller.rb
 M app/controllers/posts_controller.rb
 M app/views/posts/_form.html.erb
 M app/views/posts/_post.html.erb
 M app/views/posts/edit.html.erb
 M app/views/posts/index.html.erb
 M app/views/posts/new.html.erb
 M app/views/posts/show.html.erb
 M config/application.rb
 M prompts/00-context.md
?? app/views/shared/
?? prompts/01-views.md

M = แก้ไฟล์เดิม · ?? = ไฟล์/โฟลเดอร์ใหม่ที่ git ยังไม่รู้จัก · ต้องมีครบ 14 บรรทัดนี้ ไม่มากไม่น้อย (ถ้ามีไฟล์อื่นโผล่ เช่น ไฟล์ทดลองจากบทก่อน ให้จัดการก่อน) · แล้วเก็บด้วยข้อความ commit ตามสไลด์หน้า 22 (ผลจริง):

$ git add .
$ git commit -m "views: index + show + seed"
[my-day3 265cf20] views: index + show + seed
 14 files changed, 121 insertions(+), 60 deletions(-)
 create mode 100644 app/views/shared/_pagination.html.erb
 create mode 100644 prompts/01-views.md

my-day3 คือ branch ที่สร้างไว้ในบท 120 · เลข commit (265cf20) ของคุณจะต่าง · ตัวเลข 14 files changed, 121 insertions(+), 60 deletions(-) ควรตรงกัน ถ้าพิมพ์ตามบทเรียนครบทุกตัวอักษร (ต่างนิดหน่อยได้ถ้า Gemfile.lock ได้ pagy รุ่นอื่น)

✅ จบ Lab: โค้ดของคุณตอนนี้คือจุดเช็คพอยต์ A ที่บท 127–129 ใช้ต่อ · บล็อกหน้า (Lab Vibe 2 บท 129) จะเอาหน้าพวกนี้ไปแต่งให้ดูเป็นเว็บด้วย daisyUI

ตามไม่ทัน: มี branch สำเร็จรูปของอาจารย์ day3-a-done แต่ไฟล์ข้างในต่างจากบทเรียนนี้มาก (ดูตารางท้ายบท) · บทต่อ ๆ ไปอ้างโค้ดตามบทนี้ จึงแนะนำให้พิมพ์ตามบทนี้

💬 ติดตรงไหน ถามได้เลย — ถ้าหน้าพังด้วยข้อความที่ไม่มีในบทนี้ หรือ query ไม่ใช่ 3 ส่งสามอย่างไปถาม senior หรือ AI: log ของ request นั้นทั้งก้อน (จาก Started GET ถึง Completed) · ไฟล์ที่เพิ่งแก้ · ผลของ git diff

ภารกิจ: เติมกฎที่เจอเองลง 00-context.md

สไลด์หน้า 22: ทุกครั้งที่ AI พลาดเรื่องเดิมซ้ำ ให้เติมเป็นกฎถาวรลง 00-context.md · ไฟล์นี้จะโตขึ้นเรื่อย ๆ ตลอดวัน และนั่นคือสิ่งที่ควรเกิด — มันคือบันทึกว่าทีมเราเจออะไรมาแล้ว

🎯 ภารกิจ Lab Vibe 1 (~10 นาที)

  1. เลือกหนึ่งเรื่องจากตาราง "AI พลาดประจำ" ที่ยังไม่มีใน prompts/00-context.md (เปิดไฟล์อ่านก่อน — กฎ 1–8 มีอะไรแล้วบ้าง)
  2. เปิด prompts/00-context.md · เพิ่มหัวข้อใหม่ ท้ายไฟล์ (ใต้ "รูปแบบการตอบ") ชื่อ ## กฎที่ทีมเราเจอเอง แล้วเขียนกฎ 1 ข้อ · กฎที่ดี ตรวจได้ด้วยคำสั่ง และบอกว่าให้ทำอะไรแทน ไม่ใช่แค่ "ระวัง…"
  3. บันทึกไฟล์ แล้ว git diff prompts/00-context.md
  4. ถ้าพอใจ git add prompts/00-context.md && git commit -m "prompts: เพิ่มกฎจาก Lab Vibe 1"

ควรเห็น: ใต้บรรทัด @@ … @@ ของ git diff มีบรรทัดเดิมสามบรรทัด (ขึ้นต้นด้วยช่องว่าง) ตามด้วยบรรทัดใหม่ที่ขึ้นต้นด้วย + ทั้งหมด · ไม่มีบรรทัดที่ขึ้นต้นด้วย - ใต้ @@ เลย

ถ้าเจออาการนี้ แปลว่า…

ตัวอย่างหนึ่ง (เรื่อง pagy รุ่นเก่า) · ผล git diff จริง (เลข index … บรรทัดที่สองของคุณจะต่างตามข้อความที่เขียน):

diff --git a/prompts/00-context.md b/prompts/00-context.md
index 53783b1..c0426ef 100644
--- a/prompts/00-context.md
+++ b/prompts/00-context.md
@@ -33,3 +33,8 @@
 - บอกชื่อไฟล์ทุกไฟล์ที่แก้หรือสร้าง
 - ถ้าต้องมี migration ให้เขียนมาด้วย
 - อย่าเพิ่ง refactor อย่างอื่นที่ไม่ได้ขอ
+
+## กฎที่ทีมเราเจอเอง
+
+- pagy รุ่น 43: include Pagy::Method และ pagy(..., limit: 10)
+  ห้ามใช้ Pagy::Backend หรือ items: ของรุ่นเก่า

ตัวอย่างกฎอื่นที่ดี (ตรวจได้ + บอกทางแทน):

  • - truncate ข้อความไทยต้องมี separator: " " (ตัดที่ช่องว่าง) — ตรวจด้วย grep หา truncate( แล้วดูว่ามี separator
  • - ห้ามใช้ <%== (คือ raw) · grep ตรวจด้วย "raw\|html_safe\|<%=="
  • - index ที่แสดงข้อมูลว่างใช้ render(@x) || ... ไม่ใช้ @x.any? (เปลือง query)

กฎที่ไม่ดี: "ระวังเรื่อง performance" · "เขียนโค้ดให้ปลอดภัย" — AI อ่านแล้วไม่รู้ว่าต้องทำอะไรต่าง และเราตรวจไม่ได้ว่ามันทำตามไหม · เขียนกฎแล้วก็ยังต้องตรวจ — กฎใน context เป็น "คำแนะนำ" ไม่ใช่การบังคับ (บท 121) · ใช้วิธีในทางเลือกด้านบนดูว่า AI ยังพลาดข้อนั้นไหม

Quiz

ข้อ 1 · หน้า /posts ของ Lab นี้ไปตู้ 3 ครั้ง · ครั้งที่เป็น SELECT COUNT(*) FROM "posts" มีไว้ทำอะไร?

ตัวเลือกที่ 2 ถูก · บรรทัด ↳ ใต้ COUNT ชี้ไปที่ posts_controller.rb:6 (บรรทัดที่เรียก pagy) และผลของมันคือ หน้า 1 / 3 บนแถบ

ตัวเลือกที่ 1 ผิด — ตัวเช็คว่างของ scaffold คือ Post Exists? (SELECT 1 AS one … LIMIT 1) ที่เราเอาออกไปแล้ว · ตัวเลือกที่ 3 ผิด — bullet ไม่ยิง query เอง มันแค่ดูว่าแอปยิงอะไร · ตัวเลือกที่ 4 ผิด — includes ใช้ WHERE id IN (5, 4, 3, 2, 1) จากบทความที่หยิบมาแล้ว ไม่ต้องนับก่อน

จุดที่ต้องจำ: อ่าน log ให้ดูบรรทัด ↳ ด้วย — มันบอกว่าโค้ดบรรทัดไหนทำให้ไปตู้

ข้อ 2 · AI ส่งงานมา หน้าตาสวย มีแถบเลขหน้าครบ · คุณรัน grep -rn "raw\|html_safe" app/views แล้วไม่มี output · ใน index มี <%== @pagy.series_nav %> — ควรตัดสินยังไง?

ตัวเลือกที่ 4 ถูก · ผลจริงในบท: grep ของสไลด์ไม่เจอ แต่ grep ที่เติม \|<%== เจอทันที · และ <%= @pagy.series_nav %> (เท่ากับหนึ่งตัว) โชว์ HTML เป็นตัวหนังสือ — แปลว่า <%== คือการแกะซองจริง

ตัวเลือกที่ 1 ผิดตรงที่ "grep ไม่เจอ = ผ่าน" — ตัวตรวจมีรูรั่ว · และกฎ "ห้ามแกะซอง" ช่วยให้ทั้งทีมไม่ต้องมานั่งแยกว่าตัวไหนปลอดภัย · ตัวเลือกที่ 2 ผิด — <%== ไม่เหมือน <%= (ผลจริงต่างกันชัด) · ตัวเลือกที่ 3 ผิดเหตุผล — การใช้ gem ไม่ผิด (เราก็ใช้ pagy.page_url) ที่ผิดคือการแกะซอง

จุดที่ต้องจำ: ก่อนเชื่อตัวตรวจ ลองใส่บั๊กให้มันจับก่อน · ถ้าจับไม่ได้ แก้ตัวตรวจ

ข้อ 3 · partial _post ใช้ทั้งหน้า index (render(@posts)) และ show (render @post, full: true) · ถ้าใน partial เขียน @post.title แทน post.title จะเกิดอะไร?

ตัวเลือกที่ 1 ถูก · ผลจริงเมื่อลองเปลี่ยนเป็น @post.title ใน partial ของ Lab ที่เสร็จแล้ว: GET /posts → 500 ActionView::Template::Error (undefined method 'title' for nil) · GET /posts/30 → 200 (แบบเดียวกับ predict ในขั้น 3)

ตัวเลือกที่ 2 ผิด — Rails ไม่แปลงให้ · @post ใน partial คือการไปควานถาดหน้าครัว ซึ่งหน้า index มีแต่ @posts · ตัวเลือกที่ 3 ผิด — strict locals คุมแค่ "ของที่ยื่นมา" ไม่ได้ห้ามการใช้ @ (ถ้าห้าม หน้า show ก็ต้องพังด้วย แต่ผลจริงได้ 200) · ตัวเลือกที่ 4 กลับด้าน — full: true เป็น local แยก ไม่เกี่ยวกับ @post

จุดที่ต้องจำ: partial ที่แตะ @ "ใช้ได้แค่ครัวเดียว" — ตรวจด้วย grep "@" ในไฟล์ที่ขึ้นต้นด้วย _ ไม่ใช่ด้วยการเปิดดูหน้าเดียว

ข้อ 4 · AI เขียน db/seeds.rb ด้วย Post.create!(title: …) 30 บรรทัด · รัน bin/rails db:seed ครั้งแรกได้ 30 บทความ · รันครั้งที่สองจะได้อะไร และควรแก้ยังไง?

ตัวเลือกที่ 3 ถูก · create! = "สร้างแผ่นใหม่เสมอ" · seeds ของบท 120 ใช้ find_or_create_by! จึงรันสองรอบแล้วได้ posts: 30 เท่าเดิม (ผลจริงในขั้นตรวจรับ)

ตัวเลือกที่ 1 ผิด — create! ไม่ถามตู้ก่อน · ตัวเลือกที่ 2 ผิด — ตาราง posts ไม่มี unique index ที่ title (บทความชื่อซ้ำได้ตามธุรกิจ) จึงไม่ error และการเพิ่ม index เพื่อแก้ seed คือแก้ผิดที่ · ตัวเลือกที่ 4 ผิด — "อย่ารันซ้ำ" ไม่ใช่การแก้ เพื่อนร่วมทีม (หรือ AI) จะรันซ้ำแน่นอน

จุดที่ต้องจำ: ออกแบบให้ทำซ้ำได้ ดีกว่าหวังว่าจะไม่มีใครทำซ้ำ — ประโยค ② ของ Day 3

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริงต้องสนใจไหม
22description[0..80] "พังกับภาษาไทย" (นัยว่า truncate ไม่พัง)truncate(…, length: 80) ก็ตัดกลางคำ วรรณยุกต์หายได้ (ผลจริง "ของหวานที...") ต้องเติม separator: " "สนใจ — ขั้น 3
22ตรวจด้วย grep -rn "raw\|html_safe" app/viewsจับ <%== (ตัวย่อของ raw) ไม่ได้ · ผลจริงไม่มี output ทั้งที่มีสนใจ — เติม \|<%==
22ตามไม่ทัน git checkout day3-a-donebranch ของอาจารย์ใช้ i18n · rails-i18n · daisyUI · seeds คนละชุด ไฟล์ต่างจากบทเรียนแค่รู้ไว้
21ข้อกำหนด "db/seeds.rb สร้าง 5 หมวด 30 โพสต์"ทำไว้แล้วตั้งแต่บท 120 · Lab นี้แค่ตรวจว่ารันซ้ำได้แค่รู้ไว้
18 · 21"21 query → 2" · เกณฑ์ "ไม่เกิน 3 query"ของเราหน้าละ 10 และ pagy เพิ่ม COUNT: ไม่มี includes = 12 (5 cached) · มี = 3 พอดีแค่รู้ไว้

อ้างอิง