Skip to content

refactor(web): adopt <DataState> in HubChat / coach / digest#1726

Merged
Skords-01 merged 1 commit into
mainfrom
devin/1777916279-hubchat-coach-digest-datastate-adoption
May 4, 2026
Merged

refactor(web): adopt <DataState> in HubChat / coach / digest#1726
Skords-01 merged 1 commit into
mainfrom
devin/1777916279-hubchat-coach-digest-datastate-adoption

Conversation

@Skords-01
Copy link
Copy Markdown
Owner

@Skords-01 Skords-01 commented May 4, 2026

Summary

Адаптуємо <DataState> у HubChat / coach / digest зоні apps/web/src/core/**. Інспекція всіх Skeleton-споживачів і панельних loading-ladder-ів показала, що єдина точка з канонічним 4-станним контрактом (skeleton → error → empty → content) — WeeklyDigestCard.DigestContent. Інші компоненти з пропозиції 2.8 не мають panel-level skeleton-у:

  • AssistantAdviceCard (core/insights/AssistantAdviceCard.tsx) — inline <p>Готую пораду…</p> без Skeleton import. Завжди має кеш last-good insight (через safeReadStringLS), тож loading-стейт є transient inline-аффордансом, не панельним skeleton-ом.
  • HubChatHistoryDrawer (core/hub/HubChatHistoryDrawer.tsx) — local-first; sessions приходить пропом, без async fetch / Skeleton import. Empty-state — inline mini-state у scrollable списку.
  • HubChat.tsx / HubChatBody.tsx / HubChatComposer.tsx — стрімінг-стейт повідомлень, без Skeleton import; isPending керує SSE-progress UI, не панельним skeleton-swap-ом.

Тому скоуп цього PR — WeeklyDigestCard.DigestContent 4-state ladder → <DataState> (єдиний Skeleton-споживач у core/insights). Це закриває весь <DataState> consumer-adoption блок Phase 2 ініціативи 0011.

Що змінилось

apps/web/src/core/insights/WeeklyDigestCard.tsx:

  • Додано імпорт DataState + DataStateQueryLike.
  • DigestContent (внутрішній компонент) переписаний з 3 ранніх return-ів (if (loading) ... if (error) ... if (!hasData) ...) на єдиний <DataState>-wrapper з 4 слотами.
  • LoadingSpinner (Skeleton/SkeletonText matched-shape loader на 4 module-row-и) залишений без змін, передається як skeleton.
  • error/empty слоти — JSX-константи у closure, мають доступ до isCurrentWeek/onGenerate через scope.
  • isEmpty checker: (d) => !d || !(d.finyk || d.fizruk || d.nutrition || d.routine) — повторює прев hasData логіку (digest існує і має data хоча б у одному модулі).
  • Body-рендер рухається в children: (d) => <>...</> — використовує d замість prop digest.

Truth-table (поведінка 1:1 з prev)

loading error digest (hasData) isCurrentWeek Раніше Тепер
true <LoadingSpinner /> skeleton (data=undefined)
false "msg" true error block + retry error slot (DataState "error wins")
false "msg" false error block без retry error slot (DataState "error wins")
false null null true empty + Згенерувати btn empty slot
false null null false empty без btn ("не збережено") empty slot
false null hasData=false true empty + Згенерувати btn empty slot (isEmpty=true)
false null hasData=true true content body children(d)
false null hasData=true false content body (без update btn) children(d) (без update btn)

Mapping:

const digestQuery: DataStateQueryLike<DigestPayload | null> = {
  data: loading ? undefined : (digest ?? null),
  isLoading: loading,
  isError: !!error && !loading,
  error: error ? new Error(error) : null,
};

Force data: undefined під час loading зберігає попередній if (loading) short-circuit (стале digest приховується під час regen). isError: !!error && !loading гарантує, що під час regen-error spinner показується першим (як раніше), а error-block тільки після того, як loading осідає.

Тести

apps/web/src/core/insights/WeeklyDigestCard.datastate.test.tsx (новий, 5 кейсів):

  1. Loading skeletonloading=truearia-busy skeleton; жодного "Згенерувати" / "Спробувати знову" афорданс-у.
  2. Empty (current week)loading=false, digest=null, isCurrentWeek=true → empty-slot з кнопкою "Згенерувати звіт".
  3. Empty (past week)isCurrentWeek=false → empty-slot з текстом "Звіт за цей тиждень не збережено", БЕЗ "Згенерувати" кнопки.
  4. Error (current week)loading=false, error="..." → error-slot з retry-кнопкою.
  5. Contentloading=false, digest={finyk: {...}} → content body з "Переглянути як сторіс" CTA.

Тести явно реєструють afterEach(cleanup) — apps/web vitest setup (src/test/setup.ts) не реєструє RTL auto-cleanup, тож без cleanup-у попередній render leaks DOM в наступний кейс.

Існуючий WeeklyDigestCard.collapse.test.tsx (2 кейси на header collapse-control) проходить без змін.

Governing Skill

  • Primary skill: .agents/skills/sergeant-web-ui/SKILL.md
  • Secondary skill (if truly needed): .agents/skills/sergeant-feature-delivery/SKILL.md (для DataState consumer-adoption pattern, який розкатуємо інкрементально по модулях)

Playbook

  • Primary playbook: n/a — це pure presentational rewrite під canonical <DataState> контракт. Жодних playbook-ів для DS migration sweep-ів немає (поки що); патерн фіксується PRs 2.4–2.7 як reference.
  • Why this playbook: see above.
  • If no playbook matched, why: DataState consumer-adoption — це initiative 0011 Phase 2 work, який зараз триває; playbook буде створено після того, як ESLint rule (PR 2.9) буде enforced.

Verification

pnpm --filter @sergeant/web typecheck                                           # exit 0
pnpm --filter @sergeant/web exec eslint src/core/insights/WeeklyDigestCard.tsx \
  src/core/insights/WeeklyDigestCard.datastate.test.tsx                         # clean
pnpm --filter @sergeant/web exec vitest run src/core                            # 91 files, 1136/1136 pass
pnpm --filter @sergeant/web exec vitest run src/core/insights/WeeklyDigestCard \
  src/core/insights/WeeklyDigestCard.datastate                                  # 7/7 pass

Additional checks:

  • Local smoke / manual validation completed (4-state ladder тест-кейси покривають всі гілки)
  • Surface-specific checks completed (web typecheck, eslint, vitest)

Docs and Governance

  • I updated docs that changed with the behavior, contract, workflow, or rollout. (буде в окремому doc-update PR після цього)
  • I checked whether AGENTS.md needed an update. (no change — DataState contract уже задокументовано в попередніх PR)
  • I checked whether a playbook or skill needed an update. (sergeant-web-ui skill вже описує DataState; новий — в окремому doc PR після того, як 2.9 ESLint rule landings)
  • I checked whether governance docs or review docs needed an update. (no change у hard-rules)

Updated docs:

  • буде окремий docs(docs): record 0011 PR 2.8 ... PR

Risk and Rollout

  • User-visible risk: мінімальний. Поведінка 1:1 з попередньою (truth-table вище). Єдина потенційна різниця — DataState додає wrapper <div> навколо рендереного слоту (без класу), що може вплинути на flexbox-edge-cases у parent-і. Перевірено: parent (WeeklyDigestCard outer card) — block-level rounded-2xl border bg-panel, всі діти — звичайні block-level <div>-и. Extra wrapper не змінює layout.
  • Rollout / deploy order: регулярний Vercel deploy на merge до main. Без feature-flag-у (це presentation-only refactor).
  • Backout plan: revert single commit на main; WeeklyDigestCard.tsx поверне 3 ранніх return-и. Тест-файл можна видалити окремо або залишити (він valid для будь-якої реалізації, поки контракт-prop-и DigestContent stable).

Hard Rule #15

  • I read AGENTS.md before coding.
  • Internal docs I touched are in Ukrainian.
  • I did not use --no-verify.

Reviewer Notes

  • DataState error слот — function-form () => errorSlot (а не errorSlot як ReactNode), бо TS не звужує union без callsite, але runtime однаково (DataState не передає err/retry у errorSlot).
  • isError: !!error && !loading — навмисно False під час loading, щоб preserve "spinner wins" precedence з оригіналу. Якщо рев'юер вважає, що error-during-regen має показуватись одразу — змінити на !!error і прибрати && !loading.
  • WeeklyDigestCard.datastate.test.tsx явно додає afterEach(cleanup) — це local fix; глобальний RTL auto-cleanup для всіх web-тестів — окремий refactor (поза скоупом цього PR).
  • Pre-existing CI failures на main (governance sync drift, server apiV1.test.ts, mobile build vite build vs @sergeant/db-schema/sqlite, E2E auth, Argos visual regression) — не від цього PR; той самий набір падає на 2.4 (refactor(web): adopt <DataState> in finyk Mono panels #1703), 2.5 (refactor(web): adopt <DataState> in fizruk Workouts journal #1709), 2.6 (refactor(web): adopt <DataState> in nutrition panels #1713), 2.7 (refactor(web): adopt <DataState> in routine panels #1714).

Summary by cubic

Refactored WeeklyDigestCard.DigestContent to use @shared/components/ui/DataState, unifying the 4-state flow (skeleton → error → empty → content) without changing behavior. Adds focused tests to cover all states.

  • Refactors
    • Replaced early returns with <DataState> and a DataStateQueryLike mapping: data=undefined while loading, isError only when not loading, error passed through.
    • Kept existing LoadingSpinner as skeleton; moved body to children(d); added isEmpty to mirror previous hasData check.
    • Added apps/web/src/core/insights/WeeklyDigestCard.datastate.test.tsx with 5 cases (skeleton, empty current/past, error, content) and explicit RTL cleanup.

Written for commit d2152bb. Summary will update on new commits.

Summary by CodeRabbit

  • Tests

    • Added comprehensive test suite for Weekly Digest Card covering loading states, error handling, empty states, and content rendering scenarios.
  • Refactor

    • Enhanced Weekly Digest Card component state management patterns for improved handling of loading, error, empty, and success states.

Перевіряння всіх Skeleton-споживачів у `apps/web/src/core/**` показало,
що єдине місце з канонічним 4-станним loading-ladder-ом (skeleton →
error → empty → content) — `WeeklyDigestCard.DigestContent`. Усі інші
"HubChat / coach / digest"-related точки з пропозиції PR 2.8:

- `AssistantAdviceCard` — без skeleton, лише inline `Готую пораду…` text.
  Завжди має локальний кеш (last-good insight). DataState не потрібен.
- `HubChatHistoryDrawer` — local-first, sessions передаються пропом.
  Empty-state inline.
- `HubChat.tsx` / `HubChatBody` / `HubChatComposer` — no Skeleton import,
  стрімінг-стейт повідомлень.

Тому скоуп цього PR — `DigestContent` 4-state ladder → `<DataState>`.

Поведінка 1:1 з оригіналом:
- `loading=true` форсує `data: undefined`, що тригерить skeleton-слот
  (= попередній `if (loading) return <LoadingSpinner />` short-circuit;
  стале digest приховується під час regen).
- `error && !loading` форсує `isError: true`, DataState's "error wins"
  гілка вибирає error-слот (= попередній `if (error)`).
- `data: digest ?? null` + `isEmpty(d) = !d || !(d.finyk||...)` дає
  empty-слот для no-digest або digest-without-modules.
- Інакше — children(d) рендерить existing content body.

Додано `WeeklyDigestCard.datastate.test.tsx` (5 кейсів × 4 стани +
empty-past-week варіант) із RTL-cleanup після кожного render-у
(apps/web vitest setup не реєструє auto-cleanup за замовчуванням).

Refs initiative `docs/initiatives/0011-foundation-adoption-and-process-discipline.md` PR 2.8.
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sergeant Ready Ready Preview, Comment May 4, 2026 5:47pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

📝 Walkthrough

Walkthrough

This PR refactors WeeklyDigestCard to use the DataState declarative render-prop component for managing loading, error, and empty states, replacing early-return conditional logic. A comprehensive test suite verifies correct rendering across five distinct states: loading, no digest (current week), not stored (past week), error with retry, and success with content.

Changes

WeeklyDigestCard DataState Adoption

Layer / File(s) Summary
Core Component Refactoring
apps/web/src/core/insights/WeeklyDigestCard.tsx
Imports DataState and DataStateQueryLike. Replaces if (loading), if (error), and if (!hasData) early returns with a DataState wrapper. Adds a typed digestQuery that forces data to undefined during loading, sets isError only when error exists and not loading, and maps states to errorSlot and emptySlot JSX blocks.
Test Coverage
apps/web/src/core/insights/WeeklyDigestCard.datastate.test.tsx
Adds five test cases verifying rendering behavior: (1) loading skeleton with aria-busy label, (2) generate button when no digest on current week, (3) "not stored" message when no digest on past week, (4) error message and retry button on error state, (5) digest content and stories view button when digest exists.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

size/M

Poem

🐰 A digest card once had returns galore,
Now DataState renders what came before!
Five tested states hop through with grace,
Loading, error, empty—each in its place.
The rabbit rejoices—control flow's refined! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor(web): adopt in HubChat / coach / digest' clearly and specifically describes the main change—adopting the DataState component pattern across the digest/coach domain in the web app.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devin/1777916279-hubchat-coach-digest-datastate-adoption

Review rate limit: 5/10 reviews remaining, refill in 26 minutes and 9 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/core/insights/WeeklyDigestCard.tsx`:
- Around line 248-253: digestQuery's error field is set unconditionally which
can make DataState render the error slot during loading if a prior error exists;
update the construction of digestQuery (type DataStateQueryLike) so the error is
only populated when not loading (e.g. set error to null while loading) — keep
isLoading as loading and isError as !!error && !loading, but change error to be
conditional on !loading (e.g. error: !loading && error ? new Error(error) :
null) so the skeleton has priority during regeneration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e2d2b37c-057a-4f39-887b-ad9474b8ff39

📥 Commits

Reviewing files that changed from the base of the PR and between ec1f382 and d2152bb.

📒 Files selected for processing (2)
  • apps/web/src/core/insights/WeeklyDigestCard.datastate.test.tsx
  • apps/web/src/core/insights/WeeklyDigestCard.tsx

Comment on lines +248 to +253
const digestQuery: DataStateQueryLike<DigestPayload | null> = {
data: loading ? undefined : (digest ?? null),
isLoading: loading,
isError: !!error && !loading,
error: error ? new Error(error) : null,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Potential bug: error slot may render during loading if a previous error exists.

The comment explains that the skeleton should have priority during regeneration, but the error prop is set unconditionally when error is truthy. DataState's "error wins" check evaluates isError === true || queryError != null, so a non-null error will trigger the error slot even when isError is false.

If React Query's useMutation clears mutation.error when a new mutation starts, this works by coincidence. However, the component shouldn't rely on that implicit behavior.

Proposed fix: guard error prop with loading check
 const digestQuery: DataStateQueryLike<DigestPayload | null> = {
   data: loading ? undefined : (digest ?? null),
   isLoading: loading,
   isError: !!error && !loading,
-  error: error ? new Error(error) : null,
+  error: !loading && error ? new Error(error) : null,
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const digestQuery: DataStateQueryLike<DigestPayload | null> = {
data: loading ? undefined : (digest ?? null),
isLoading: loading,
isError: !!error && !loading,
error: error ? new Error(error) : null,
};
const digestQuery: DataStateQueryLike<DigestPayload | null> = {
data: loading ? undefined : (digest ?? null),
isLoading: loading,
isError: !!error && !loading,
error: !loading && error ? new Error(error) : null,
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/core/insights/WeeklyDigestCard.tsx` around lines 248 - 253,
digestQuery's error field is set unconditionally which can make DataState render
the error slot during loading if a prior error exists; update the construction
of digestQuery (type DataStateQueryLike) so the error is only populated when not
loading (e.g. set error to null while loading) — keep isLoading as loading and
isError as !!error && !loading, but change error to be conditional on !loading
(e.g. error: !loading && error ? new Error(error) : null) so the skeleton has
priority during regeneration.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 4, 2026

⏱️ CI Pipeline Duration Report

Based on the last 50 successful runs on the default branch.

Overall Pipeline

Metric Value
p50 6m 26s
p95 7m 55s
p99 9m 3s
Current run 8m 54s
vs p95 +12.4%

Trend (last 20 runs): ▃▃▁▂▃▃▃▂▃▃▂▂▄▃▃▆▅▄█▆

Per-Job Breakdown

Job p50 p95 p99 Current vs p95
Accessibility (axe-core) 2m 5s 2m 21s 2m 23s 0s -100.0%
Commit messages (commitlint) 0s 0s 0s 42s N/A
Critical-flow E2E (Playwright) 1m 36s 1m 44s 1m 44s 6m 6s +251.9%
Migration lint (AGENTS rule 0s 0s 0s 17s N/A
Pipeline duration (p95 trend) 26s 27s 27s
Secret scan (gitleaks) 8s 11s 11s 12s +9.1%
Smoke E2E (Playwright) 1m 26s 1m 40s 1m 40s
Test coverage (vitest) 2m 4s 2m 33s 2m 33s 2m 9s -15.7%
Workflow lint (actionlint) 7s 7s 7s 6s -14.3%
check 4m 12s 4m 54s 5m 6s 53s -82.0%
tsconfig strict guard (PR-1.A) 5s 14s 14s 6s -57.1%

Skords-01 pushed a commit that referenced this pull request May 4, 2026
Status header: додано PR 2.8 (#1726) і змінено сигнал з "2.8
HubChat/coach/digest залишається" на "2.9 ESLint rule і 2.1
ManualExpenseSheet залишаються" — consumer-adoption блок Phase 2
закрито.

Phase 2 table row 2.8: ETA "+3 дні" → "Opened 2026-05-04 — #1726",
файл `core/insights/WeeklyDigestCard.tsx` `DigestContent` 4-state
ladder як єдиний Skeleton-based panel-loading site у HubChat /
coach / digest зоні `core/**`.

Footnote: додано пояснення per PR 2.8 чому інші HubChat / coach /
digest "панелі" з пропозиції (`HubChatHistoryPanel`,
`CoachInsightsPanel`, `DigestPanel`) не мали реальних DataState-
targets — `AssistantAdviceCard` без skeleton imports і кешує
last-good insight, `HubChatHistoryDrawer` local-first, `HubChat.tsx`
/ `HubChatBody` / `HubChatComposer` стрімлять без panel-skeleton-у.

Refs initiative 0011 PR 2.8.
@Skords-01 Skords-01 merged commit 498cb65 into main May 4, 2026
30 of 44 checks passed
@Skords-01 Skords-01 deleted the devin/1777916279-hubchat-coach-digest-datastate-adoption branch May 4, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants