# Bug — Sisir Frontend Lintas Halaman

| | |
|---|---|
| **Cakupan** | Seluruh `resources/js` — bukan satu halaman, melainkan sapuan menyeluruh atas alur klien |
| **Entry point** | [resources/js](../../resources/js) (172 berkas, ±31k baris di luar `wayfinder`/`actions`/`routes`) |
| **Ditemukan** | 28 Agustus 2026, branch `dev` (`8745f18`) |
| **Status** | 🔴 **Terbuka** — belum ada yang dikerjakan |
| **Metode** | Pembacaan kode menyeluruh + pemeriksaan silang ke aturan validasi backend dan ke sumber `@inertiajs/core@3.0.3` di `node_modules`. `npm run types:check` dan `npx eslint .` sama-sama **hijau**, jadi tidak satu pun temuan di bawah bisa ditangkap perkakas statis |

Lima belas temuan. Tiga di antaranya mematikan fungsi yang terlihat bekerja
(paginasi yang tidak memaginasi, tombol simpan yang selalu ditolak, snapshot history
yang dibuang), sisanya gesekan alur yang menumpuk.

---

## Ringkasan

| ID | Masalah | Dampak | Tingkat |
|---|---|---|---|
| [F-1](#f-1--paginasi-my-tasks-tidak-pernah-mengganti-isi-tabel) | Paginasi My Tasks memuat prop yang tidak ada | Halaman 2 dst. tidak pernah tampil | **Kritis** |
| [F-2](#f-2--required_proof_type-kosong-menolak-simpan-tanpa-pesan-yang-bisa-dipahami) | `required_proof_type` kosong / `'none'` ditolak server | Sebagian task utama tidak bisa diedit sama sekali | **Tinggi** |
| [F-3](#f-3--replacestate-membuang-snapshot-history-inertia) | `replaceState({}, …)` menghapus `history.state.page` | Back/forward & restorasi scroll rusak di halaman project | **Tinggi** |
| [F-4](#f-4--status-on_hold--cancelled-jadi-jalan-buntu-di-taskdetailsheet) | `on_hold`/`cancelled` tanpa jalan keluar bagi pelaksana | Task mandek, bar aksi tak berarti | **Sedang** |
| [F-5](#f-5--draft-di-create-sheet-nyangkut-setelah-cancel) | Draft Create sheet bertahan setelah Cancel | Isian lama muncul lagi tanpa diminta | **Sedang** |
| [F-6](#f-6--badge-notifikasi-bisa-menggelembung) | `unreadCount` naik di luar penjagaan duplikat | Badge berbohong setelah reconnect | **Sedang** |
| [F-7](#f-7--loadmore-notifikasi-tanpa-penjaga-ref) | `loadMore` dijaga state, bukan ref | Request kursor yang sama ditembak berkali-kali | **Sedang** |
| [F-8](#f-8--polling-project-memicu-fetch-deep-link-berulang) | Polling menyegarkan identitas `fetchTaskById` | Task deep-link di-fetch ulang tiap 10 detik | **Sedang** |
| [F-9](#f-9--dua-formatrelativetime-dengan-bahasa-berbeda) | Dua `formatRelativeTime` berbeda bahasa | Waktu relatif campur Inggris–Indonesia | **Rendah** |
| [F-10](#f-10--editingid-bisa-menggantung) | `editingId` menggantung tanpa `editingComment` | Tombol pesan mati serentak, Enter salah kirim | **Rendah** |
| [F-11](#f-11--mention-dropdown-tidak-mengikuti-posisi-kursor) | Mention tak disegarkan saat kursor pindah | Enter memilih nama alih-alih mengirim | **Rendah** |
| [F-12](#f-12--cleartaskquery-membuat-pageurl-inertia-berbeda-dari-windowlocation) | `page.url` Inertia basi setelah `clearTaskQuery` | `?task=` bisa hidup lagi lewat link paginasi | **Rendah** |
| [F-13](#f-13--isdirty-selalu-true-di-konteks-tanpa-project) | `isDirty` membandingkan nilai yang beda skema | Tombol Save Changes tak pernah nonaktif | **Rendah** |
| [F-14](#f-14--resolvedappearance-basi-saat-tema-os-berubah) | `notify()` tidak dipanggil saat tema OS berubah | Laten — belum ada pembacanya | **Rendah** |
| [F-15](#f-15--toast-error-generik-menelan-pesan-validasi-server) | Toast generik menelan pesan validasi server | User tidak tahu apa yang salah | **Rendah** |

**F-1, F-3, F-5, F-7, dan F-15 berbagi satu akar.** Baca bagian berikut lebih dulu.

---

## Akar masalah bersama — pola yang benar sudah ada, tapi tidak dipakai konsisten

Lima dari lima belas temuan bukan soal pola yang belum ditemukan. Polanya **sudah
terpasang di repo ini**, sering kali di berkas sebelahnya, kadang lengkap dengan komentar
yang menjelaskan kenapa. Yang terjadi adalah satu pemanggil tertinggal.

| Persoalan | Sudah benar di | Tertinggal di |
|---|---|---|
| `only` partial reload harus menyebut prop halaman | 4 halaman admin + [ProjectInfo.tsx:498](../../resources/js/components/projects/ProjectInfo.tsx:498) | [my-tasks/index.tsx:336](../../resources/js/pages/my-tasks/index.tsx:336) — F-1 |
| `history.state` diteruskan, bukan ditimpa `{}` | [media/show.tsx:57](../../resources/js/pages/media/show.tsx:57), [use-task-deep-link.ts:62](../../resources/js/hooks/use-task-deep-link.ts:62) | [projects/show.tsx:171](../../resources/js/pages/projects/show.tsx:171) — F-3 |
| State form tinggal di komponen ber-`key` **di dalam** `SheetContent` | [EditTaskSheet.tsx:62](../../resources/js/components/tasks/EditTaskSheet.tsx:62), [MyTaskFormSheet.tsx:88](../../resources/js/components/my-tasks/MyTaskFormSheet.tsx:88) | `CreateTaskSheet`, `CreateRecurringTaskSheet` — F-5 |
| Penjaga request beruntun disimpan di ref, bukan state | [TaskDiscussionSection.tsx:420](../../resources/js/components/tasks/TaskDiscussionSection.tsx:420) | [NotificationBell.tsx:60](../../resources/js/components/NotificationBell.tsx:60) — F-7 |
| Toast error menampilkan `Object.values(errors)[0]` | `CreateTaskSheet`, `EditTaskSheet`, `TaskDetailSheet`, `TaskProofSection` | [CreateRecurringTaskSheet.tsx:81](../../resources/js/components/recurring-tasks/CreateRecurringTaskSheet.tsx:81) — F-15 |

Dua di antaranya bahkan sudah punya komentar penjelas di tempat yang benar. `media/show.tsx`
menulisnya persis:

> `window.history.state` diteruskan apa adanya, bukan ditimpa `{}`, supaya snapshot halaman
> milik Inertia tetap utuh di entri history yang baru.
> — [media/show.tsx:54-55](../../resources/js/pages/media/show.tsx:54)

Komentar itu ada karena seseorang pernah tertimpa masalahnya. Halaman project tidak pernah
ikut disisir.

**Konsekuensinya untuk perbaikan:** kelimanya diselesaikan dengan menyalin pola yang sudah
terbukti, bukan merancang yang baru. Tidak ada keputusan produk yang menghalangi.

---

## Detail

### F-1 — Paginasi My Tasks tidak pernah mengganti isi tabel

**Tingkat: Kritis**

Halaman My Tasks memanggil paginasinya tanpa menyebut prop apa pun:

```tsx
// my-tasks/index.tsx:336-341
<DataPagination
    pagination={pagination}
    windowed
    size="sm"
/>
```

Sehingga jatuh ke nilai bawaan komponen:

```tsx
// DataPagination.tsx:105
only = ['data'],
```

Yang lalu dipakai apa adanya oleh tiap tombol halaman:

```tsx
// DataPagination.tsx:135-144
router.get(link.url, {}, { only, preserveScroll: true, preserveState: true, replace: true });
```

Tapi controller-nya tidak pernah mengirim prop bernama `data`:

```php
// PersonalTaskController.php:62-71
return Inertia::render('my-tasks/index', [
    'tasks'      => PersonalTaskResource::collection($paginated->getCollection()),
    'pagination' => $this->pagination($paginated),
    'filters'    => [...],
    'summary'    => PersonalTaskSummary::for($user),
]);
```

Header `X-Inertia-Partial-Data: data` membuat adapter Laravel hanya menyelesaikan prop
bernama `data` — tidak ada — jadi respons pulang tanpa `tasks` maupun `pagination`. Di sisi
klien, partial reload **me-merge**, bukan mengganti:

```js
// @inertiajs/core@3.0.3 — dist/index.js:2518
pageResponse.props = { ...page.get().props, ...pageResponse.props };
```

`tasks` dan `pagination` lama bertahan utuh. **Hasilnya: URL berubah jadi `?page=2`,
tombol halaman 2 menyala aktif, dan tabelnya tetap menampilkan halaman 1.** Tidak ada
error, tidak ada indikasi apa pun.

Kelima pemanggil `DataPagination` yang lain semuanya menyebut prop-nya sendiri
(`['users','pagination']`, `['divisions','pagination']`, `['activities','pagination']`,
`['projects','pagination']`, `['activities']`). My Tasks satu-satunya yang lupa — dan
karena `only` punya nilai bawaan, tidak ada yang mengeluh.

**Perbaikan:**

```tsx
<DataPagination
    pagination={pagination}
    only={['tasks', 'pagination', 'summary']}
    windowed
    size="sm"
/>
```

`summary` ikut karena badge "N overdue" di kepala halaman membacanya.

**Catatan desain.** Nilai bawaan `only = ['data']` adalah jebakan yang sama menunggu
pemanggil berikutnya: prop bernama `data` hanya ada di halaman divisi. Menjadikan `only`
wajib (`only: string[]`, tanpa default) akan memindahkan kegagalan ini ke `tsc` — pola yang
sudah dipakai `FilterParamNames` di [TaskList.tsx:120-127](../../resources/js/components/TaskList.tsx:120),
lengkap dengan alasannya:

> Sengaja menuntut seluruh key, bukan Partial: override yang tertinggal satu key akan
> mengirim nama parameter default sementara server membaca nama skemanya sendiri, dan
> filter itu diam-diam tidak berfungsi.

---

### F-2 — `required_proof_type` kosong menolak simpan tanpa pesan yang bisa dipahami

**Tingkat: Tinggi**

Ketiga checkbox bukti di Create dan Edit sheet menulis string kosong saat dilepas:

```tsx
// CreateTaskSheet.tsx:404-411 (pola yang sama di :428, :452 dan EditTaskSheet.tsx:479, :502, :525)
onCheckedChange={(checked) => {
    setFormData({ ...formData, requiredProofType: checked ? 'file' : '' });
}}
```

Server tidak menerima string kosong:

```php
// TaskController.php:48  (store)
'required_proof_type' => ['required_without:parent_id', Rule::in(['file', 'image', 'link'])],
// TaskController.php:153 (update)
'required_proof_type' => ['nullable', Rule::requiredIf(fn () => ! $task->parent_id), Rule::in(['file', 'image', 'link'])],
```

Dan `taskFormSchema` tidak memvalidasi field ini sama sekali — hanya `title` dan `dueDate`
([lib/validation/task.ts](../../resources/js/lib/validation/task.ts)). Jadi tidak ada
penjagaan klien: user melepas centang, menekan Simpan, dan menerima toast berisi pesan
Laravel mentah tanpa tahu kolom mana yang dimaksud.

**Turunannya lebih berat dari itu.** Task utama yang kolomnya `NULL` di basis data
dipetakan menjadi string `'none'`:

```php
// TaskResource.php:37
'required_proof_type' => $this->required_proof_type ?? 'none',
```

`EditTaskSheet` menaruh nilai itu apa adanya ke state, karena `'none'` truthy:

```tsx
// EditTaskSheet.tsx:166
requiredProofType: task.requiredProofType || 'file',
```

Tidak ada checkbox yang tercentang (ketiganya membandingkan dengan `'file'`/`'image'`/`'link'`),
sehingga layarnya terlihat seperti keadaan sah. Tapi menyimpan perubahan apa pun — mengganti
judul saja — mengirim `'none'`:

```tsx
// EditTaskSheet.tsx:234-236
required_proof_type: task.parentId ? null : formData.requiredProofType,
```

`Rule::in(['file','image','link'])` menolaknya. **Task itu tidak bisa diedit sama sekali
sampai user kebetulan mencentang salah satu jenis bukti** — yang berarti diam-diam mengubah
persyaratan task, bukan sekadar memperbaiki judulnya.

Task dengan kolom `NULL` bukan hipotesis: kolomnya lahir `nullable`
([2026_05_11_034149_add_required_proof_type_to_tasks_table.php:15](../../app/Domains/Task/Database/Migrations/2026_05_11_034149_add_required_proof_type_to_tasks_table.php:15)),
jadi setiap task utama yang dibuat sebelum migrasi itu masuk kategori ini.

**Perbaikan** menuntut satu keputusan kecil: apakah task utama boleh tanpa bukti sama
sekali? Aturan `required_without:parent_id` di `store()` bilang tidak. Kalau memang begitu,
UI-nya yang salah — ketiga checkbox itu semestinya **radio group tanpa opsi kosong**, karena
melepas centang adalah keadaan yang server tidak pernah izinkan. Kalau ternyata boleh, tambah
`'none'` ke `Rule::in` di kedua tempat dan tampilkan sebagai opsi eksplisit. Apa pun
pilihannya, `taskFormSchema` perlu ikut menjaga field ini supaya kegagalannya berhenti di
klien.

---

### F-3 — `replaceState({}, …)` membuang snapshot history Inertia

**Tingkat: Tinggi**

Mengganti tab di halaman project menulis langsung ke history browser:

```tsx
// projects/show.tsx:166-172
const handleTabChange = (val: string) => {
    setActiveTab(val);

    const url = new URL(window.location.href);
    url.searchParams.set('tab', val);
    window.history.replaceState({}, '', url.toString());
};
```

Argumen pertama `replaceState` **mengganti seluruh** state entri history saat ini. Inertia
menyimpan snapshot halamannya persis di situ:

```js
// @inertiajs/core@3.0.3 — dist/index.js:1338, 1357, 1364, 1477
const pageData = page2 ?? window.history.state?.page;
...
if (!window.history.state?.page) { return; }
...
return !isServer2 && !!window.history.state?.page;
```

Menimpanya dengan `{}` menghapus `state.page` untuk entri itu. Akibatnya, untuk entri
tersebut: restorasi posisi scroll berhenti bekerja (`saveScrollPositions` langsung
`return`), dan `decrypt()` melempar `Error("Unable to decrypt history")` karena
`decryptPageData(undefined)` tidak menghasilkan apa-apa.

Dua tempat lain di codebase sudah melakukannya dengan benar — dan salah satunya menjelaskan
kenapa, lihat [akar masalah bersama](#akar-masalah-bersama--pola-yang-benar-sudah-ada-tapi-tidak-dipakai-konsisten) di atas.

**Perbaikan:**

```tsx
window.history.replaceState(window.history.state, '', url.toString());
```

---

### F-4 — Status `on_hold` / `cancelled` jadi jalan buntu di TaskDetailSheet

**Tingkat: Sedang**

Alur status yang dikenali panel detail hanya empat:

```tsx
// TaskDetailSheet.tsx:155-174
const TASK_STATUS_FLOW = task?.parentId
    ? ['waiting', 'in_progress', 'completed']
    : ['waiting', 'in_progress', 'review', 'completed'];
...
const currentIdx = TASK_STATUS_FLOW.indexOf(currentStatus);
const nextStatus = currentStatus !== 'review' && currentIdx !== -1 && currentIdx < TASK_STATUS_FLOW.length - 1
    ? TASK_STATUS_FLOW[currentIdx + 1]
    : null;
```

`on_hold` dan `cancelled` tidak ada di dalamnya, jadi `currentIdx === -1` dan `nextStatus`
null. Bar bawah lalu jatuh ke cabang terakhir:

```tsx
// TaskDetailSheet.tsx:973-977
) : (
    <div className="flex-1 text-center text-xs font-medium text-muted-foreground select-none">
        Special Status Active
    </div>
)}
```

Dropdown override manual memfilter keluar `waiting`, `in_progress`, dan `review`, lalu
`completed` hanya diloloskan untuk subtask ([TaskDetailSheet.tsx:1003-1041](../../resources/js/components/tasks/TaskDetailSheet.tsx:1003)).
Untuk task utama yang sisanya cuma `on_hold` dan `cancelled` — dua status yang sudah
disandangnya.

Manajer masih punya pintu belakang lewat Edit sheet, yang menawarkan Select status lengkap
([EditTaskSheet.tsx:390-399](../../resources/js/components/tasks/EditTaskSheet.tsx:390)) dan
tombolnya memang tampil untuk `on_hold` (yang disembunyikan hanya `review` dan `completed`).
**Pelaksana tidak punya pintu itu:**

```tsx
// TaskDetailSheet.tsx:132
const canManualOverride = can.manageTasks;
```

Ia melihat bar aksi yang terbuka lewat `isAssignee`, berisi kalimat "Special Status Active"
dan tidak satu pun kontrol. Status ini nyata dipakai — `TaskSeeder:241`, `TaskFactory:101`,
dan Select di Edit sheet menawarkannya secara langsung.

Perbaikan minimalnya: perlakukan `on_hold` seperti `completed` yang bisa di-*reopen* —
tawarkan satu tombol kembali ke `in_progress` dengan izin yang datang dari policy, pola yang
sudah dipakai `canReopen` ([TaskDetailSheet.tsx:117-122](../../resources/js/components/tasks/TaskDetailSheet.tsx:117)).
`cancelled` sengaja dibiarkan buntu kalau memang final — tapi kalimatnya perlu berbunyi
"Task dibatalkan", bukan "Special Status Active".

---

### F-5 — Draft di Create sheet nyangkut setelah Cancel

**Tingkat: Sedang**

`CreateTaskSheet` menyimpan seluruh state form di komponen **luar**:

```tsx
// CreateTaskSheet.tsx:57-67
export function CreateTaskSheet({ open, ... }) {
    const [processing, setProcessing] = useState(false);
    const [formData, setFormData] = useState({ title: '', ... });
```

Komponen itu selalu ter-mount — `TaskList` merendernya di luar kondisi apa pun
([TaskList.tsx:1294](../../resources/js/components/TaskList.tsx:1294)) — jadi menutup sheet
lewat Cancel, Escape, atau klik overlay tidak mereset apa pun. Reset hanya terjadi di
`onSuccess` ([CreateTaskSheet.tsx:147-158](../../resources/js/components/tasks/CreateTaskSheet.tsx:147)),
dan efek `open` di atasnya cuma menyentuh `projectId`/`assigneeId`. Buka lagi: judul,
deskripsi, deadline, dan pilihan bukti yang lama masih terpasang.

`CreateRecurringTaskSheet` mengulang pola yang sama
([CreateRecurringTaskSheet.tsx:45-57](../../resources/js/components/recurring-tasks/CreateRecurringTaskSheet.tsx:45)).

Pola yang benar sudah dipakai dua saudaranya — form ditaruh di komponen ber-`key`
**di dalam** `SheetContent`, dan Radix meng-unmount isi sheet saat tertutup sehingga
form-nya lahir bersih tiap kali:

```tsx
// MyTaskFormSheet.tsx:88-107 — komentar aslinya menjelaskan maksudnya
<SheetContent ...>
    <MyTaskForm
        key={task ? `edit-${task.id}` : `create-${parentId ?? 'root'}`}
        ...
    />
</SheetContent>
```

> Pembungkus sheet. Isinya di-remount lewat `key` tiap kali task yang dibuka berganti,
> sehingga form selalu mulai dari nilai yang benar tanpa perlu menyinkronkan state lewat
> effect.

---

### F-6 — Badge notifikasi bisa menggelembung

**Tingkat: Sedang**

Penyisipan notifikasi realtime sudah menolak duplikat, tapi penghitungnya tidak ikut dijaga:

```tsx
// use-notifications.tsx:167-188
setNotifications((prev) => {
    if (prev.some((item) => item.id === id)) {
        return prev;                       // ← duplikat ditolak di sini
    }
    ...
    return [incoming, ...prev];
});
setUnreadCount((prev) => prev + 1);        // ← tapi ini selalu jalan
```

Event `TaskNotification` yang tiba dua kali — replay setelah Echo reconnect, atau siaran
yang sampai lewat dua jalur — menaikkan badge tanpa menambah satu pun baris di dropdown.
Angkanya baru dikoreksi kalau user membuka dropdown (`refresh()` menyetel ulang dari
`response.unread_count`).

Perbaikannya: pindahkan kenaikan ke dalam updater yang sama, atau hitung ulang dari daftar
setelah penyisipan.

---

### F-7 — `loadMore` notifikasi tanpa penjaga ref

**Tingkat: Sedang**

```tsx
// NotificationBell.tsx:60-66
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
    const { scrollHeight, scrollTop, clientHeight } = event.currentTarget;

    if (scrollHeight - scrollTop - clientHeight < LOAD_MORE_THRESHOLD_PX) {
        void loadMore();
    }
};
```

Penjaga di dalam `loadMore` adalah **state React**:

```tsx
// use-notifications.tsx:101
if (!nextCursor || isLoading) { return; }
```

Beberapa event scroll dalam satu frame sama-sama melihat `isLoading === false` dan menembak
request untuk kursor yang sama. Datanya tidak rusak — `setNotifications` menyaring id yang
sudah dikenal — tapi request-nya terbuang, dan pada koneksi lambat efeknya berlipat.

Persoalan yang persis identik sudah dipecahkan di panel diskusi, dengan komentar yang
menjelaskan kenapa state saja tidak cukup:

```tsx
// TaskDiscussionSection.tsx:420-444
// Penanda dibaca dari ref, bukan dari state: dua event gulir yang tiba
// sebelum React sempat render ulang sama-sama melihat `isLoadingOlder`
// masih `false`, lalu meminta potongan yang sama dua kali.
if (!container || !hasMore || isLoadingOlder || pendingOlderRef.current || ...) { return; }

pendingOlderRef.current = true;
...
void loadOlderComments().finally(() => { pendingOlderRef.current = false; });
```

---

### F-8 — Polling project memicu fetch deep-link berulang

**Tingkat: Sedang**

Konteks mapper di halaman project di-memo terhadap objek `project`:

```tsx
// projects/show.tsx:193-211
const taskMapContext = useMemo(() => ({ ... }), [project]);
```

Sementara halaman itu mem-polling prop `project` tiap sepuluh detik:

```tsx
// projects/show.tsx:115-119
const { start, stop } = usePoll(
    10000,
    { only: ['tasks', 'taskSummary', 'project', 'activities'] },
    { autoStart: false },
);
```

Tiap respons membawa objek `project` baru → memo berganti identitas → `fetchTaskById`
berganti identitas ([use-task-deep-link.ts:32-55](../../resources/js/hooks/use-task-deep-link.ts:32),
`useCallback` dengan dep `context`) → efek deep-link di `TaskList` menyala ulang:

```tsx
// TaskList.tsx:481-509
useEffect(() => {
    if (!viewingTaskId || taskFromList || !fetchTaskById) { return; }
    ...
}, [viewingTaskId, taskFromList, fetchTaskById, onSheetClose]);
```

Selama sheet deep-link terbuka untuk task yang tidak termuat di halaman aktif, task itu
di-fetch ulang **tiap sepuluh detik**. Docblock `useTaskDeepLink` sudah memperingatkannya:

> Harus stabil antar render (konstanta modul atau `useMemo`), karena identitas
> `fetchTaskById` ikut menentukan kapan efek TaskList mengambil ulang task.

`divisions/tasks/show.tsx` memakai konstanta modul (`DIVISION_MAP_CONTEXT`) dan karena itu
bebas. `useMemo` di halaman project memenuhi huruf peringatannya tapi tidak maksudnya —
dep-nya sendiri tidak stabil.

**Terkait, di berkas yang sama:** polling hanya di-pause untuk dialog level halaman.

```tsx
// projects/show.tsx:107-113
const isAnyDialogOpen =
    isEditOpen || isDuplicateOpen || isDeleteOpen ||
    isAddMemberOpen || isEditRoleOpen || isRemoveMemberOpen;
```

`TaskDetailSheet`, `CreateTaskSheet`, dan `EditTaskSheet` hidup di dalam `TaskList` dan tidak
ikut terhitung — padahal komentar di atas variabel itu berbunyi *"Polling di-pause saat ada
dialog level-halaman terbuka agar reload tidak menimpa data yang sedang diedit user"*, dan
justru ketiga sheet itulah tempat user mengedit.

---

### F-9 — Dua `formatRelativeTime` dengan bahasa berbeda

**Tingkat: Rendah**

Ada dua implementasi terpisah dengan nama yang sama.

[utils.ts:121-150](../../resources/js/lib/utils.ts:121) — dipakai panel diskusi. Docblock-nya
menjanjikan Bahasa Indonesia, keluarannya Inggris:

```ts
/**
 * Format waktu relatif berbahasa Indonesia untuk timestamp diskusi
 * ("Baru saja", "5 menit lalu", "2 jam lalu").
 */
export function formatRelativeTime(dateString: string): string {
    ...
    if (diffSec < 45) { return 'Just now'; }        // ← Inggris
    if (diffMin < 60) { return `${diffMin}m ago`; }
    if (diffHour < 24) { return `${diffHour}h ago`; }

    return date.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })...
}
```

Di atas 24 jam ia jatuh ke jam:menit **tanpa tanggal** — di panel diskusi itu masih terbaca
karena ada pemisah tanggal di atas grupnya, tapi fungsinya sendiri tidak menjamin konteks itu.

[NotificationBell.tsx:197-232](../../resources/js/components/NotificationBell.tsx:197) — versi
lokal berbasis `Intl.RelativeTimeFormat('id')`, benar-benar berbahasa Indonesia.

Yang kedua lebih baik dan tanpa dependensi tambahan. Angkat ke `lib/utils.ts`, buang yang
pertama, dan perbaiki docblock-nya sekalian.

---

### F-10 — `editingId` bisa menggantung

**Tingkat: Rendah**

Pesan yang sedang disunting dicari ulang dari daftar tiap render:

```tsx
// TaskDiscussionSection.tsx:469-472
const editingComment =
    editingId === null ? null : (comments.find((c) => c.id === editingId) ?? null);
```

Kalau barisnya hilang dari `comments` sementara `editingId` masih terisi — refetch yang
memotong jendela, atau daftar yang tergeser — tiga hal terjadi sekaligus:

1. Banner "Mengedit pesan" hilang ([:1007](../../resources/js/components/tasks/TaskDiscussionSection.tsx:1007), dijaga `editingComment`);
2. Tombol edit & hapus mati di **seluruh** pesan, karena penjaganya membaca `editingId`, bukan `editingComment`:
   ```tsx
   // TaskDiscussionSection.tsx:752-754
   const areActionsHidden = Boolean(comment.status) || editingId !== null;
   ```
3. `canSend` jatuh ke cabang pesan baru ([:483-487](../../resources/js/components/tasks/TaskDiscussionSection.tsx:483)),
   sehingga menekan Enter **mengirim teks suntingan sebagai pesan baru** alih-alih menyimpannya.

Tidak ada jalan keluar selain menutup panel. Perbaikannya satu baris: turunkan `areActionsHidden`
dan penjaga lain dari `editingComment`, bukan dari `editingId` mentah.

---

### F-11 — Mention dropdown tidak mengikuti posisi kursor

**Tingkat: Rendah**

`syncFromCursor` hanya dipanggil dari `onChange` textarea
([TaskDiscussionSection.tsx:1094](../../resources/js/components/tasks/TaskDiscussionSection.tsx:1094)).
Menggerakkan kursor dengan panah, klik, atau Home/End tidak menyegarkannya, jadi `query`
bertahan pada keadaan terakhir saat mengetik.

Akibatnya dropdown bisa tetap terbuka padahal kursor sudah tidak berada di sebuah sebutan —
dan di situ Enter memilih nama, bukan mengirim pesan:

```tsx
// TaskDiscussionSection.tsx:555-568
if (mention.isOpen) {
    ...
    if (e.key === 'Enter' || e.key === 'Tab') {
        e.preventDefault();
        chooseMention(mention.suggestions[mention.activeIndex]);
        return;
    }
}
```

Cukup panggil `syncFromCursor` juga dari `onKeyUp`/`onClick` textarea dengan
`selectionStart` yang berlaku saat itu.

---

### F-12 — `clearTaskQuery` membuat `page.url` Inertia berbeda dari `window.location`

**Tingkat: Rendah**

Deep-link dibaca dari URL milik Inertia, tapi dibersihkan lewat history browser:

```ts
// use-task-deep-link.ts:28-30
const taskId = new URLSearchParams(page.url.split('?')[1] ?? '').get('task');

// use-task-deep-link.ts:59-67
const clearTaskQuery = useCallback(() => {
    const url = new URL(window.location.href);
    url.searchParams.delete('task');
    window.history.replaceState(window.history.state, '', url.pathname + url.search);
}, []);
```

`replaceState` tidak menyentuh `page.url`, jadi Inertia tetap mengira URL-nya mengandung
`?task=`. Sebagian besar jalur aman (`router.reload` membaca `window.location`, dan efek
filter `TaskList` menyusun query-nya sendiri), tapi link paginasi Laravel dibangun dari
query string request asli — jadi `?task=` bisa hidup lagi begitu user pindah halaman, dan
refresh sesudahnya membuka sheet yang tidak diminta.

Membersihkannya lewat `router.replace`/`router.visit` dengan `preserveState` akan membuat
kedua sisi sepakat.

---

### F-13 — `isDirty` selalu true di konteks tanpa project

**Tingkat: Rendah**

```tsx
// EditTaskSheet.tsx:156
projectId: withoutProject ? '' : String(task.projectId),

// EditTaskSheet.tsx:198-206
const isDirty =
    ...
    formData.projectId !== String(task.projectId) ||
    ...
```

Saat `withoutProject`, nilai awalnya sengaja dikosongkan tapi tetap dibandingkan dengan
`String(task.projectId)`. Keduanya tidak pernah sama, jadi `isDirty` permanen `true` dan
tombol Save Changes tidak pernah nonaktif di halaman task divisi — satu-satunya pemanggil
yang mengirim `withoutProject`.

---

### F-14 — `resolvedAppearance` basi saat tema OS berubah

**Tingkat: Rendah (laten)**

```ts
// use-appearance.tsx:71
const handleSystemThemeChange = (): void => applyTheme(currentAppearance);
```

Listener perubahan tema OS memperbarui kelas DOM tapi tidak memanggil `notify()`, sehingga
`useSyncExternalStore` tidak dibangunkan. Komponen yang membaca `resolvedAppearance` akan
memegang nilai lama sampai ada penyebab render lain.

Belum menggigit: ketiga pemakai `useAppearance` hari ini hanya membaca `appearance`
(`AppearanceTabs`, `UserMenuContent`, `ui/sonner`). Dicatat supaya tidak jadi kejutan bagi
pemakai berikutnya.

---

### F-15 — Toast error generik menelan pesan validasi server

**Tingkat: Rendah**

```tsx
// CreateRecurringTaskSheet.tsx:78-82
onError: (err) => {
    console.error(err);
    toast.error('Failed to create automation');
},
```

Aturan validasi template recurring cukup spesifik — `assignee_ids` harus tepat satu,
`schedule_day` wajib untuk frekuensi tertentu, `due_time` berformat `HH:MM`
([StoreRecurringTaskRequest.php](../../app/Domains/Scheduler/Http/Requests/StoreRecurringTaskRequest.php)) —
dan tidak satu pun sampai ke layar. Semua sheet lain memakai
`Object.values(errors)[0] || '<fallback>'`. `use-task-comments.ts:171-189` bahkan sudah
menyediakan helper `firstErrorMessage()` beserta alasan kenapa pesan server yang dipakai:

> Aturan lampiran tinggal di server dan pesannya sudah spesifik; menyalinnya ke sini hanya
> melahirkan rumus kedua yang cepat atau lambat berbeda.

---

## Yang diperiksa dan ternyata bersih

Dicatat supaya sisiran berikutnya tidak mengulang jalan yang sama.

| Area | Hasil |
|---|---|
| Loop sinkronisasi filter (`TaskList`, `useRecurringTasksFilters`) | Bersih. Penjaga by-value (`extraQueryKey`/`reloadOnlyKey`) dan pembanding terhadap `filters` milik server sudah benar; ini perbaikan dari [recurring-tasks B-2](recurring-tasks.md) dan masih terpasang |
| Antrean kirim komentar | Bersih. `sendQueueRef` menjaga urutan, `clientKey` mencegah remount balon, dan `isOwnPendingEcho` menutup balon kembar |
| Guard StrictMode di `useMentionAutocomplete` | Bersih. Penjaganya kode task, bukan flag per-pemasangan efek — persis alasan yang ditulis di komentarnya |
| Anchoring scroll panel diskusi | Bersih. `useLayoutEffect` dengan tiga cabang (restore / first-anchor / near-bottom) sudah menutup kasus yang ada |
| Reopen deep-link `?task=` dua kali | Bersih. Inertia me-remount komponen halaman pada tiap visit non-`preserveState`, jadi `handledInitialTaskId` tidak pernah menghalangi pembukaan kedua |
| `mapApiTasksToTaskoTasks` | Bersih. Fallback `null`/`undefined` konsisten, dan `parentAssigneeIds` diisi dari peta id, bukan dari urutan |
| Perbandingan id number vs string | Bersih. Seluruh titik sensitif memakai `String(a) === String(b)` |
