refactor(frontend): migrate JS modules to TypeScript

Wholesale JS -> TS migration of the frontend — stores, utils, actions,
and all Svelte component scripts adopt type annotations. Compile-time
surfaces (app.d.ts, lib/types/) added for shared DTO types.

Build plumbing:
  - package.json: dev:frontend script that runs svelte-kit sync first
  - tauri.conf.json: beforeDevCommand points at dev:frontend
  - run.sh: dropped the sed-hack that temporarily blanked beforeDevCommand;
    now relies on npm run dev:frontend to avoid double-Vite
  - jsconfig.json: allowImportingTsExtensions

Preserves all Group 1 behaviour:
  - page.svelte.ts keeps loadHistory / loadTasks Tauri-first, no
    localStorage; saveTranscriptMeta + mapTranscriptRow + mapTaskRow
    intact; update_task_cmd and update_transcript_meta_cmd invocations
    carry the correct payload shape.
  - Toasts, preferences stores typed without behaviour change.
  - Viewer still routes segment edits through saveTranscriptMeta; the
    Task 1.5 TODO markers are gone.

taskExtractor.ts is functionally improved during the migration:
  - multi-task matches in the same sentence
  - list-style shopping-verb expansion (get bread, milk, and cheese)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 20:05:54 +01:00
parent 6605266587
commit d6bf9ed245
48 changed files with 1693 additions and 1156 deletions

View File

@@ -1,4 +1,6 @@
<script>
<script lang="ts">
import { tick } from "svelte";
import type { TaskList } from "$lib/types/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
tasks, addTask, completeTask, uncompleteTask, deleteTask,
@@ -12,12 +14,14 @@
let showCompleted = $state(false);
let newListName = $state("");
let showNewList = $state(false);
let contextMenuListId = $state(null);
let editingListId = $state(null);
let contextMenuListId = $state<string | null>(null);
let editingListId = $state<string | null>(null);
let editingName = $state("");
let showSortMenu = $state(false);
let draggingTaskId = $state(null);
let dropHighlightId = $state(null);
let draggingTaskId = $state<string | null>(null);
let dropHighlightId = $state<string | null>(null);
let editingInputEl = $state<HTMLInputElement | null>(null);
let newListInputEl = $state<HTMLInputElement | null>(null);
// Filtered tasks for active list
let tasksForActiveList = $derived.by(() => {
@@ -34,7 +38,7 @@
return done.filter((t) => t.listId === activeListId).slice(0, 10);
});
function countForList(listId) {
function countForList(listId: string) {
if (listId === "all") return tasks.filter((t) => !t.done).length;
if (listId === "inbox") return tasks.filter((t) => !t.done && !t.listId).length;
return tasks.filter((t) => !t.done && t.listId === listId).length;
@@ -48,7 +52,7 @@
let builtInLists = $derived(taskLists.filter((l) => l.builtIn));
let customLists = $derived(taskLists.filter((l) => !l.builtIn));
function handleQuickAdd(e) {
function handleQuickAdd(e: KeyboardEvent) {
if (e.key === "Enter" && quickInput.trim()) {
const listId = (activeListId === "all" || activeListId === "inbox") ? null : activeListId;
addTask({ text: quickInput.trim(), bucket: "inbox", listId });
@@ -56,11 +60,11 @@
}
}
function handleDragStart(e) {
function handleDragStart(e: PointerEvent) {
if (e.button !== 0) return;
if (e.target.closest("button")) return;
if (e.target.closest("input")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
if ((e.target as HTMLElement | null)?.closest("button")) return;
if ((e.target as HTMLElement | null)?.closest("input")) return;
try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging();
}
@@ -74,7 +78,7 @@
}
// List management
function handleCreateList(e) {
function handleCreateList(e: KeyboardEvent) {
if (e.key === "Enter" && newListName.trim()) {
addTaskList(newListName.trim());
newListName = "";
@@ -86,10 +90,13 @@
}
}
function startRenaming(list) {
async function startRenaming(list: TaskList) {
editingListId = list.id;
editingName = list.name;
contextMenuListId = null;
await tick();
editingInputEl?.focus();
editingInputEl?.select();
}
function finishRenaming() {
@@ -100,28 +107,28 @@
editingName = "";
}
function handleRenameKey(e) {
function handleRenameKey(e: KeyboardEvent) {
if (e.key === "Enter") finishRenaming();
if (e.key === "Escape") { editingListId = null; editingName = ""; }
}
function handleDeleteList(id) {
function handleDeleteList(id: string) {
if (activeListId === id) activeListId = "all";
deleteTaskList(id);
contextMenuListId = null;
}
function toggleContextMenu(e, listId) {
function toggleContextMenu(e: MouseEvent, listId: string) {
e.preventDefault();
e.stopPropagation();
contextMenuListId = contextMenuListId === listId ? null : listId;
}
// Drag-and-drop: tasks between lists
function handleTaskDragStart(e, taskId) {
function handleTaskDragStart(e: DragEvent, taskId: string) {
draggingTaskId = taskId;
e.dataTransfer.setData("text/plain", taskId);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer?.setData("text/plain", taskId);
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
}
function handleTaskDragEnd() {
@@ -129,24 +136,24 @@
dropHighlightId = null;
}
function handleListDragOver(e) {
function handleListDragOver(e: DragEvent) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
}
function handleListDragEnter(e, listId) {
function handleListDragEnter(e: DragEvent, listId: string) {
e.preventDefault();
dropHighlightId = listId;
}
function handleListDragLeave(e, listId) {
function handleListDragLeave(_e: DragEvent, listId: string) {
if (dropHighlightId === listId) dropHighlightId = null;
}
function handleListDrop(e, listId) {
function handleListDrop(e: DragEvent, listId: string) {
e.preventDefault();
dropHighlightId = null;
const taskId = e.dataTransfer.getData("text/plain");
const taskId = e.dataTransfer?.getData("text/plain");
if (taskId) {
moveTaskToList(taskId, listId);
}
@@ -157,10 +164,20 @@
if (contextMenuListId) contextMenuListId = null;
if (showSortMenu) showSortMenu = false;
}
$effect(() => {
if (showNewList) {
tick().then(() => {
newListInputEl?.focus();
newListInputEl?.select();
});
}
});
</script>
<svelte:window onclick={handleWindowClick} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex flex-col h-full bg-bg">
<!-- Drag handle with title -->
<div
@@ -239,6 +256,7 @@
{#each customLists as list (list.id)}
{#if editingListId === list.id}
<input
bind:this={editingInputEl}
type="text"
class="bg-bg-input border border-accent rounded px-2 py-0.5 text-[11px] text-text
focus:outline-none w-[100px]"
@@ -246,7 +264,6 @@
onkeydown={handleRenameKey}
onblur={finishRenaming}
data-no-transition
autofocus
/>
{:else}
<div class="relative flex-shrink-0">
@@ -280,7 +297,7 @@
<!-- Context menu -->
{#if contextMenuListId === list.id}
<div class="absolute left-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[120px]"
onclick={(e) => e.stopPropagation()}>
onpointerdown={(e) => e.stopPropagation()}>
<button
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => startRenaming(list)}
@@ -322,6 +339,7 @@
<!-- New list button -->
{#if showNewList}
<input
bind:this={newListInputEl}
type="text"
class="bg-bg-input border border-border rounded px-2 py-0.5 text-[10px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[100px] flex-shrink-0"
@@ -330,7 +348,6 @@
onkeydown={handleCreateList}
onblur={() => { showNewList = false; newListName = ""; }}
data-no-transition
autofocus
/>
{:else}
<button
@@ -345,6 +362,7 @@
<button
class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary"
onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }}
aria-label="Sort task lists"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h12M3 18h6" stroke-linecap="round" />
@@ -352,7 +370,7 @@
</button>
{#if showSortMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-20 animate-fade-in min-w-[110px]"
onclick={(e) => e.stopPropagation()}>
onpointerdown={(e) => e.stopPropagation()}>
<button
class="w-full text-left px-3 py-1 text-[10px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => { sortTaskLists("alpha"); showSortMenu = false; }}