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,5 @@
<script>
<script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte";
import { Channel, invoke } from "@tauri-apps/api/core";
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
// @ts-nocheck
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { page, settings } from "$lib/stores/page.svelte.js";

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
// @ts-nocheck
import { onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
// @ts-nocheck
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";

View File

@@ -1,4 +1,6 @@
<script>
<script lang="ts">
import { tick } from "svelte";
import type { TaskBucket, TaskList } from "$lib/types/app";
import { invoke } from "@tauri-apps/api/core";
import {
tasks, addTask, completeTask, uncompleteTask, deleteTask, updateTask,
@@ -21,17 +23,29 @@
let newListName = $state("");
let sortMode = $state("date");
let showSortMenu = $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 editingInputEl = $state<HTMLInputElement | null>(null);
let newListInputEl = $state<HTMLInputElement | null>(null);
const buckets = [
const buckets: Array<{ id: "all" | TaskBucket; label: string }> = [
{ id: "all", label: "All" },
{ id: "inbox", label: "Inbox" },
{ id: "today", label: "Today" },
{ id: "soon", label: "Soon" },
{ id: "later", label: "Later" },
];
const taskBucketOptions: TaskBucket[] = ["today", "soon", "later"];
const effortOptions = ["quick", "medium", "deep"];
function effortRank(effort: string): number {
return EFFORT_ORDER[effort as keyof typeof EFFORT_ORDER] || 4;
}
function effortLabel(effort: string): string {
return EFFORT_LABELS[effort as keyof typeof EFFORT_LABELS] || effort;
}
let filteredTasks = $derived.by(() => {
let list = tasks.filter((t) => !t.done);
@@ -50,9 +64,9 @@
list = list.filter((t) => t.text.toLowerCase().includes(q));
}
if (sortMode === "quick-first") {
list.sort((a, b) => (EFFORT_ORDER[a.effort] || 4) - (EFFORT_ORDER[b.effort] || 4));
list.sort((a, b) => effortRank(a.effort) - effortRank(b.effort));
} else if (sortMode === "deep-first") {
list.sort((a, b) => (EFFORT_ORDER[b.effort] || 4) - (EFFORT_ORDER[a.effort] || 4));
list.sort((a, b) => effortRank(b.effort) - effortRank(a.effort));
}
return list;
});
@@ -77,7 +91,7 @@
});
let bucketCounts = $derived.by(() => {
const counts = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
const counts: Record<"all" | TaskBucket, number> = { all: 0, inbox: 0, today: 0, soon: 0, later: 0 };
for (const t of tasks) {
if (t.done) continue;
counts.all++;
@@ -86,7 +100,7 @@
return counts;
});
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;
@@ -96,27 +110,27 @@
activeListId === "all" ? "All Lists" : (taskLists.find((l) => l.id === activeListId)?.name || "Tasks")
);
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: activeBucket === "all" ? "inbox" : activeBucket,
bucket: (activeBucket === "all" ? "inbox" : activeBucket) as TaskBucket,
listId,
});
quickInput = "";
}
}
function setBucket(taskId, bucket) {
function setBucket(taskId: string, bucket: TaskBucket) {
updateTask(taskId, { bucket });
}
function setEffort(taskId, effort) {
function setEffort(taskId: string, effort: string) {
updateTask(taskId, { effort: effort === tasks.find((t) => t.id === taskId)?.effort ? "" : effort });
}
function getListName(listId) {
function getListName(listId: string | null) {
if (!listId) return null;
return taskLists.find((l) => l.id === listId)?.name || null;
}
@@ -129,7 +143,7 @@
}
}
function handleCreateList(e) {
function handleCreateList(e: KeyboardEvent) {
if (e.key === "Enter" && newListName.trim()) {
addTaskList(newListName.trim());
newListName = "";
@@ -138,10 +152,13 @@
if (e.key === "Escape") { showNewList = false; newListName = ""; }
}
function startRenaming(list) {
async function startRenaming(list: TaskList) {
editingListId = list.id;
editingName = list.name;
contextMenuListId = null;
await tick();
editingInputEl?.focus();
editingInputEl?.select();
}
function finishRenaming() {
@@ -152,17 +169,26 @@
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;
}
$effect(() => {
if (showNewList) {
tick().then(() => {
newListInputEl?.focus();
newListInputEl?.select();
});
}
});
</script>
<div class="flex flex-col h-full animate-fade-in">
@@ -297,13 +323,13 @@
{#if editingListId === list.id && !sidebarCollapsed}
<div class="px-1.5 py-0.5">
<input
bind:this={editingInputEl}
type="text"
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
onblur={finishRenaming}
data-no-transition
autofocus
/>
</div>
{:else}
@@ -356,6 +382,7 @@
<div class="px-2 py-2 border-t border-border-subtle">
{#if showNewList}
<input
bind:this={newListInputEl}
type="text"
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
@@ -364,7 +391,6 @@
onkeydown={handleCreateList}
onblur={() => { showNewList = false; newListName = ""; }}
data-no-transition
autofocus
/>
{:else}
<button
@@ -403,7 +429,7 @@
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
<div class="flex items-center gap-2 mt-1.5">
<!-- Bucket pills -->
{#each ["today", "soon", "later"] as b}
{#each taskBucketOptions as b}
<button
class="text-[10px] px-2 py-0.5 rounded-full border
{task.bucket === b
@@ -415,7 +441,7 @@
{/each}
<span class="text-border-subtle">&middot;</span>
<!-- Effort pills -->
{#each ["quick", "medium", "deep"] as e}
{#each effortOptions as e}
<button
class="text-[10px] px-2 py-0.5 rounded-full border
{task.effort === e
@@ -423,7 +449,7 @@
: 'text-text-tertiary border-transparent hover:border-border-subtle hover:text-text-secondary'}"
style="transition-duration: var(--duration-ui)"
onclick={() => setEffort(task.id, e)}
>{EFFORT_LABELS[e]}</button>
>{effortLabel(e)}</button>
{/each}
<!-- Timestamp -->
{#if task.createdAt}
@@ -457,7 +483,7 @@
class="flex items-center gap-2 text-[12px] text-text-tertiary hover:text-text-secondary mb-2"
onclick={() => showCompleted = !showCompleted}
>
<ChevronRight size={12} class="{showCompleted ? 'rotate-90' : ''}" aria-hidden="true"
<ChevronRight size={12} class={showCompleted ? "rotate-90" : ""} aria-hidden="true"
style="transition: transform var(--duration-ui)" />
Completed ({completedTasks.length})
</button>