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">
import type { ViewerSegment } from "$lib/types/app";
import { measureTextHeight } from "$lib/utils/textMeasure.js";
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
import { getPreferences } from "$lib/stores/preferences.svelte.js";
@@ -8,23 +9,39 @@
let {
segments = [],
activeSegmentIdx = -1,
matchingSegments = new Set(),
matchingSegments = new Set<number>(),
showTimestamps = true,
editingIdx = -1,
editingText = $bindable(""),
searchQuery = "",
formatTime = (t) => String(t),
onSeekToTime = () => {},
onStartEditing = () => {},
formatTime = (t: number) => String(t),
onSeekToTime = (_time: number) => {},
onStartEditing = (_idx: number) => {},
onFinishEditing = () => {},
onToggleStar = () => {},
onCopySegment = () => {},
onDeleteSegment = () => {},
highlightText = (t) => t,
} = $props();
onToggleStar = (_idx: number) => {},
onCopySegment = (_idx: number) => {},
onDeleteSegment = (_idx: number) => {},
highlightText = (t: string) => t,
} = $props<{
segments?: ViewerSegment[];
activeSegmentIdx?: number;
matchingSegments?: Set<number>;
showTimestamps?: boolean;
editingIdx?: number;
editingText?: string;
searchQuery?: string;
formatTime?: (time: number) => string;
onSeekToTime?: (time: number) => void;
onStartEditing?: (idx: number) => void;
onFinishEditing?: () => void;
onToggleStar?: (idx: number) => void;
onCopySegment?: (idx: number) => void;
onDeleteSegment?: (idx: number) => void;
highlightText?: (text: string) => string;
}>();
// Internal state
let containerEl = $state(null);
let containerEl = $state<HTMLDivElement | null>(null);
let containerHeight = $state(0);
let containerWidth = $state(0);
let scrollTop = $state(0);
@@ -46,7 +63,7 @@
const textWidth = containerWidth - 26 - timestampWidth - 24;
if (textWidth <= 0) return segments.map(() => 40);
return segments.map(seg => {
return segments.map((seg: ViewerSegment) => {
const text = seg.text?.trim() || "";
if (!text) return SEGMENT_PADDING + textLineHeight;
const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight);
@@ -75,7 +92,7 @@
// Visible segment slice with positioning
let visibleItems = $derived.by(() => {
const items = [];
const items: Array<{ seg: ViewerSegment; idx: number; top: number; height: number }> = [];
for (let i = visibleRange.start; i < visibleRange.end; i++) {
items.push({
seg: segments[i],
@@ -88,7 +105,7 @@
});
// Scroll to a specific segment index (used for active segment tracking + search)
export function scrollToSegment(idx) {
export function scrollToSegment(idx: number) {
if (!containerEl || idx < 0 || idx >= segments.length) return;
const segTop = cumulativeOffsets[idx];
const segHeight = segmentHeights[idx];
@@ -96,8 +113,8 @@
containerEl.scrollTop = segTop - viewCenter + segHeight / 2;
}
function onScroll(e) {
scrollTop = e.target.scrollTop;
function onScroll(e: Event) {
scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;
// Track selection state for expanded buffer
hasSelection = !document.getSelection()?.isCollapsed;
}
@@ -105,7 +122,7 @@
// ResizeObserver for container dimensions
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver(entries => {
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
@@ -119,7 +136,7 @@
$effect(() => {
if (activeSegmentIdx >= 0) {
// Find the index in our segments array that matches
const arrayIdx = segments.findIndex(s => s._idx === activeSegmentIdx);
const arrayIdx = segments.findIndex((s: ViewerSegment) => s._idx === activeSegmentIdx);
if (arrayIdx >= 0) {
const segTop = cumulativeOffsets[arrayIdx];
const segBottom = segTop + segmentHeights[arrayIdx];