Compare commits

53 Commits

Author SHA1 Message Date
Roberto
81fe6d29e2 perf(DateTimeField): keep typing local, memoize Calendar + SegmentSpan
Typing in a segment no longer calls onChange — local state only.
onChange now fires only on commit (Enter, calendar pick), so the
parent TaskFormDialog stops re-rendering on every keystroke (and
the heavy Calendar grid + every pill / popover / query stops
re-rendering with it).

Inside DateTimeField:
- Calendar element memoized via useMemo keyed on the committed
  date's ms — only re-renders when a full valid date is reached
  or changes.
- SegmentSpan wrapped in React.memo.
- onSegKeyDown stabilized via useCallback + functional setSeg +
  refs for order / withTime / onChange / onCommit, so its
  identity never changes.
- Per-segment ref setters cached in a useRef map so they don't
  swap identity on each render.

TaskFormDialog:
- onChange / onCommit passed to DateTimeField wrapped in useCallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:46:47 +02:00
Roberto
b2d7fa1723 fix(DateTimeField): drop value-sync useEffect that wiped partial typing
Each onChange propagated to the parent caused a re-render with a fresh
Date instance, which retriggered the value-sync effect and overwrote
the in-progress segment state. Result: after picking a day in the
calendar, typing '14' in the hour field only kept the last digit.

Initial value still seeds segment state via the useState lazy
initializer, and Radix Popover unmounts the content on close so each
open starts from the current value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:09:21 +02:00
Roberto
4c641ab93a fix(DateTimeField): autocomplete missing segments on Enter, keep popover open on calendar pick
Enter inside any segment now commits even when the value is partial.
Missing segments default to today (day, month, year) or to 00
(hour, minute). Out-of-range values clamp to their segment max,
and an impossible day-in-month (e.g. Feb 31) clamps to the last
day of the resolved month.

Calendar click no longer fires onCommit, so the Due popover stays
open and the date segments update inline. The popover closes via
Enter inside a segment, Esc, or click outside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:56:02 +02:00
Roberto
84720ff23c feat(TaskFormDialog): segment-based DateTimeField for Due, header padding, assignees kbd wrap
- New DateTimeField component: segment editor (DD/MM/YYYY HH:MM) honoring
  FormatPrefs.dateFormat. Each segment is keyboard-driven (digits to
  enter, arrows up/down to inc/dec, arrows left/right or separators to
  move, Enter commits and closes the Due popover). Calendar grid below
  stays in sync.
- TaskFormDialog: header gains px-5 pt-5 pb-2 so the title+description
  don't sit flush against the DialogContent edges.
- AssigneesList: ArrowDown on the last list item now focuses the
  "new name" Input; ArrowUp from the Input returns to the last list
  item; Esc on the Input closes the popover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:38:27 +02:00
Roberto
d7307e146a fix(TaskFormDialog): control Due popover open state for keyboard close
Add dueOpen state + onOpenChange so Esc and outside-click close the
popover consistently with the other pills; wire DateField.onCommit
to close the popover after Enter or calendar click.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:21:42 +02:00
Roberto
7d4059ca4b i18n: add tasks.newTaskDescription / editTaskDescription
Two new keys for the DialogDescription line under the TaskFormDialog
header. Added to all five supported languages (en, it, es, fr, de).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:16:56 +02:00
Roberto
9691842e79 feat(TaskFormDialog): new header, full keyboard nav, DateField-based due
Header: DialogTitle + DialogDescription, no separator border, matching
AddEventDialog.
Keyboard: pills row uses roving tabindex (←/→/↑/↓ + Home/End +
Enter-to-open). Each list popover (Project, Priority, Status,
Assignees) uses useListboxKeys for ↑/↓/Home/End/Enter/Space/Esc/Tab.
Due: replaced bespoke Calendar + hour/minute Selects with a
DateField (flat + withTime), which is keyboard-typeable and
format-prefs aware. derivePartsInTz / updateDueTime / HOURS /
MINUTES helpers removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:15:18 +02:00
Roberto
094840e671 refactor(PropertyPill): render as button with forwardRef and focus ring
Switch the trigger element from a presentational <span> to a real
<button type="button"> with forwardRef so the pill can receive keyboard
focus, be used directly as a PopoverTrigger asChild, and show a
focus-visible ring. Public API stays compatible: icon, label, value,
empty plus any standard button HTML attributes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:07:55 +02:00
Roberto
e8592b25a8 feat(date-field): add withTime and flat props
withTime renders hour/minute selects under the Calendar and appends
HH:MM to the display text when the value has a non-midnight time.
flat renders Input + Calendar (+ Time row) inline without the
internal Popover, so a caller can embed DateField inside its own
popover without nesting. Existing callers continue to work
unchanged (both props default to false).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:05:31 +02:00
Roberto
27b385df53 feat(parseDate): accept optional ' HH:MM' suffix
Existing callers unaffected: a bare date still parses to midnight as
before. New behavior: an optional trailing ' HH:MM' is stripped from
the input, the date portion goes through the existing resolution
order, and setHours/setMinutes is applied to the result. Out-of-range
times (e.g. 25:00, 12:99) return null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:01:28 +02:00
Roberto
e170844f17 feat: add useListboxKeys hook for popover list keyboard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:58:49 +02:00
Roberto
27c1194384 feat: add useRovingFocus hook for roving-tabindex groups
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:57:03 +02:00
Roberto
26ea095f60 ui(AddEventDialog): add DialogDescription to match project dialog header 2026-05-14 08:58:57 +02:00
Roberto
751d16a9f4 fix(AddEventDialog): clear focusedRowId on row blur + skip X icon in Tab cycle 2026-05-14 08:52:39 +02:00
Roberto
285214a2d2 ui(AddEventDialog): swap ToggleGroup for Tabs to match Gantt zoom selector 2026-05-14 08:38:10 +02:00
Roberto
89645f2abd polish(timeline): end-date validation + project-lock hint
- Surface invalidMessage on end DateField when end < start
- Auto-clear endDate when start changes past it
- Add title tooltip on SelectTrigger when project is locked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:06:40 +02:00
Roberto
7dadeb88fe fix(AddEventDialog): reset edit mode when removing the row being edited 2026-05-13 19:05:27 +02:00
Roberto
13531fec40 feat(timeline): keyboard nav + edit mode for staged rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:01:37 +02:00
Roberto
e254efd420 a11y(AddEventDialog): i18n staged-list label + add title aria-label 2026-05-13 18:59:44 +02:00
Roberto
6d79911414 feat(timeline): batch-stage flow in AddEventDialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:55:53 +02:00
Roberto
69a859e19f i18n(timeline): add keys for batch-add dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:52:30 +02:00
Roberto
098ce86c76 fix(EditEventDialog): remove non-null assertion via inline expression 2026-05-13 18:50:17 +02:00
Roberto
9ef809ba02 refactor(timeline): migrate EditEventDialog to DateField
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:56:32 +02:00
Roberto
024d572ebb fix(DateField): wire aria-describedby + prevent calendar-icon focus steal
- Add useId() to generate stable fieldId/errorId; link <Input> to error
  <p> via aria-describedby so screen readers announce the message.
- Add onMouseDown={e => e.preventDefault()} to the calendar icon Button
  so clicking it does not trigger onBlur on the input mid-typing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:49:38 +02:00
Roberto
d24f09bbea feat(ui): add DateField with typed entry + calendar popover 2026-05-13 17:45:51 +02:00
Roberto
56fe6c0754 i18n(date): add date keyword arrays for parseDate 2026-05-13 17:25:41 +02:00
Roberto
c76de207d7 fix(date): re-validate leap-day roll-forward; tighten parseDateRange separator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:10:08 +02:00
Roberto
4e89a7a96c feat(date): add parseDate utility with locale-aware parsing 2026-05-13 16:04:44 +02:00
Roberto
0fc3aa421e fix(adiuvAI): WsStreamEndSchema accepts null mutations/error (backend emits null for zero-tool turns) 2026-05-13 09:20:45 +02:00
Roberto
c10fbe22d7 fix(adiuvAI): cap default DialogContent at sm:max-w-lg 2026-05-13 08:34:37 +02:00
Roberto
e3e0b06fb6 fix(adiuvAI): add 3 folder actions to ToolCallActionSchema enum (caused silent WS hang) 2026-05-12 17:57:52 +02:00
Roberto
b3d85b93f1 feat(adiuvAI): drizzle-executor read action returns kind+totalSize, supports offset/length 2026-05-12 17:34:19 +02:00
Roberto
659607a1e9 fix(adiuvAI): remove card border from FolderLinkCard, inline layout 2026-05-12 14:46:09 +02:00
Roberto
80a0d2c56f fix(adiuvAI): files section uses standard project section layout 2026-05-12 14:43:38 +02:00
Roberto
66448a25f4 fix(adiuvAI): collapse chip height in compact mode to preserve hero shrink 2026-05-12 14:32:51 +02:00
Roberto
93144b9de8 fix(adiuvAI): position FolderChip right of project title 2026-05-12 14:30:10 +02:00
Roberto
b0c415f90f feat(adiuvAI): pre-flight quota check + error toasts for folder integration
Before starting an index session, scanFolder is called to count
indexable files, then BackendClient.checkFolderQuota POSTs to
/api/v1/billing/quota/check.  A 402 response becomes a TRPCError
FORBIDDEN with a QUOTA:<reason>:<message> payload.  FilesSection
catches that payload and shows a localised sonner toast via
projects.folder.errors.tooBig or monthlyExhausted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:10:14 +02:00
Roberto
8a2225da7c feat(adiuvAI): wire FolderChip + FilesSection into ProjectDetail 2026-05-12 13:04:02 +02:00
Roberto
e0c5971d20 feat(adiuvAI): add 'files' tab to ProjectTabBar 2026-05-12 13:02:01 +02:00
Roberto
a499d55636 feat(adiuvAI): FilesSection orchestrator 2026-05-12 12:50:27 +02:00
Roberto
c36890cc8b feat(adiuvAI): FolderUnlinkDialog 2026-05-12 12:49:10 +02:00
Roberto
b80ba0434b feat(adiuvAI): FolderFileList with kind filter 2026-05-12 12:48:39 +02:00
Roberto
01d3735dd1 feat(adiuvAI): FolderLinkCard component 2026-05-12 12:47:57 +02:00
Roberto
e0bcb2fe0a feat(adiuvAI): FolderChip component 2026-05-12 12:47:13 +02:00
Roberto
a1c83a6134 i18n: projects.folder keys in all 5 locales 2026-05-12 12:41:04 +02:00
Roberto
bd5e3076ed feat(adiuvAI): daily auto-rescan of stale folder links 2026-05-12 12:23:27 +02:00
Roberto
316b8fa66a feat(adiuvAI): drizzle-executor folder manifest + scoped read actions 2026-05-12 12:21:36 +02:00
Roberto
6f907f6a96 feat(adiuvAI): projectFolders tRPC router (link, unlink, scan, list) 2026-05-12 12:18:50 +02:00
Roberto
93caf0116d feat(adiuvAI): WS index session orchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:09:36 +02:00
Roberto
15af8d54e6 feat(adiuvAI): WS index session frame senders + dispatcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:06:48 +02:00
Roberto
c4ed7b3482 feat(adiuvAI): folder scanner with mtime delta 2026-05-12 12:04:04 +02:00
Roberto
066d407a5f feat(adiuvAI): folder index constants 2026-05-12 12:03:20 +02:00
Roberto
c2826ae4be feat(adiuvAI): schema for project folder integration
Add folderPath, folderLastScannedAt, folderLastScanStatus, folderTotalFiles
columns to projects table; add project_folder_files table with kind/summary
columns. Migration: 0005_slim_baron_strucker.sql.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:00:36 +02:00
36 changed files with 4484 additions and 566 deletions

View File

@@ -163,6 +163,16 @@ export class ServerError extends Error {
}
}
export class QuotaError extends Error {
constructor(
public readonly reason: 'max_files' | 'monthly_tokens',
message: string,
) {
super(message);
this.name = 'QuotaError';
}
}
// ---------------------------------------------------------------------------
// V3 stream listener types
// ---------------------------------------------------------------------------
@@ -183,6 +193,12 @@ interface JourneyListener {
reject: (err: Error) => void;
}
export interface IndexSessionListener {
onFileResult: (frame: { relPath: string; summary: string | null; tokensUsed: number; error?: string }) => void;
onProgress: (frame: { processed: number; total: number }) => void;
onDone: (status: 'completed' | 'cancelled' | 'quota_exceeded' | 'error') => void;
}
// ---------------------------------------------------------------------------
// BackendClient
// ---------------------------------------------------------------------------
@@ -206,6 +222,9 @@ export class BackendClient {
/** Journey reply listeners keyed by sessionId. */
private journeyListeners: Map<string, JourneyListener> = new Map();
/** Index session listeners keyed by sessionId. */
private indexListeners: Map<string, IndexSessionListener> = new Map();
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -564,6 +583,64 @@ export class BackendClient {
});
}
// -------------------------------------------------------------------------
// Index session WS methods
// -------------------------------------------------------------------------
/**
* Register a listener for an index session. Must be called before sending
* the first `index_session_start` for that sessionId. Listener is auto-
* removed when `index_session_done` is received.
*/
registerIndexSession(sessionId: string, listener: IndexSessionListener): void {
this.indexListeners.set(sessionId, listener);
}
/**
* Send the opening `index_session_start` frame. Must be called after
* `registerIndexSession`. Throws OfflineError if the WS is not connected.
*/
sendIndexSessionStart(sessionId: string, projectId: string, totalFiles: number): void {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
const payload = toSnakeCase({ type: 'index_session_start', sessionId, projectId, totalFiles });
logWsSend(payload);
ws.send(JSON.stringify(payload));
}
/**
* Send one batch of files (typically up to 5).
*/
sendIndexFileBatch(
sessionId: string,
files: Array<{
relPath: string;
kind: 'text' | 'image' | 'pdf' | 'docx';
content: string;
ext?: string;
mime?: string;
sizeBytes: number;
mtimeMs: number;
}>,
): void {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
const payload = toSnakeCase({ type: 'index_file_batch', sessionId, files });
logWsSend(payload);
ws.send(JSON.stringify(payload));
}
/**
* Cancel an in-flight index session.
*/
sendIndexSessionCancel(sessionId: string): void {
const ws = this.persistentWs;
if (!ws || ws.readyState !== WebSocket.OPEN) return; // best-effort
const payload = toSnakeCase({ type: 'index_session_cancel', sessionId });
logWsSend(payload);
ws.send(JSON.stringify(payload));
}
// -------------------------------------------------------------------------
// HTTP utilities
// -------------------------------------------------------------------------
@@ -678,6 +755,72 @@ export class BackendClient {
);
}
// -------------------------------------------------------------------------
// Billing quota pre-flight
// -------------------------------------------------------------------------
/**
* Pre-flight quota check for folder indexing.
*
* Calls `POST /api/v1/billing/quota/check` with `{ feature: "folder_index",
* estimated_files: estimatedFiles }`.
*
* Returns `{ ok: true }` when the backend allows the operation.
* Throws `QuotaError` when the backend responds with HTTP 402.
* Propagates `AuthExpiredError` / `OfflineError` on auth / network failure.
*/
async checkFolderQuota(estimatedFiles: number): Promise<{ ok: true }> {
const token = await getAuthManager().getAccessToken();
if (!token) throw new AuthExpiredError();
const url = `${this.baseUrl}/api/v1/billing/quota/check`;
const bodyPayload = { feature: 'folder_index', estimated_files: estimatedFiles };
logHttp('POST', url, bodyPayload);
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(bodyPayload),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
} catch (err) {
throw new OfflineError(err instanceof Error ? err.message : 'Network error');
}
logHttpResponse('POST', url, res.status);
if (res.ok) return { ok: true };
if (res.status === 402) {
let detail: { reason: 'max_files' | 'monthly_tokens'; message: string } | null = null;
try {
const body = await res.json() as { detail?: { reason: string; message: string } };
if (body.detail?.reason && body.detail?.message) {
detail = {
reason: body.detail.reason as 'max_files' | 'monthly_tokens',
message: body.detail.message,
};
}
} catch { /* ignore parse errors */ }
const reason = detail?.reason ?? 'max_files';
const message = detail?.message ?? 'Quota exceeded';
throw new QuotaError(reason, message);
}
// Other error codes
const text = await res.text().catch(() => '');
const msg = `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`;
if (res.status === 401) throw new AuthExpiredError(msg);
if (res.status === 429) throw new RateLimitError(msg);
if (res.status >= 500) throw new ServerError(msg, res.status);
throw new Error(msg);
}
// -------------------------------------------------------------------------
/**
@@ -861,6 +1004,32 @@ export class BackendClient {
}
break;
}
case 'index_file_result': {
const lis = this.indexListeners.get(frame.data.sessionId);
lis?.onFileResult({
relPath: frame.data.relPath,
summary: frame.data.summary ?? null,
tokensUsed: frame.data.tokensUsed,
error: frame.data.error,
});
break;
}
case 'index_session_progress': {
const lis = this.indexListeners.get(frame.data.sessionId);
lis?.onProgress({ processed: frame.data.processed, total: frame.data.total });
break;
}
case 'index_session_done': {
const lis = this.indexListeners.get(frame.data.sessionId);
if (lis) {
lis.onDone(frame.data.status);
this.indexListeners.delete(frame.data.sessionId);
}
break;
}
}
});
@@ -895,6 +1064,8 @@ export class BackendClient {
}
this.journeyListeners.clear();
}
// Index session listeners are fire-and-forget; just drop them on disconnect.
this.indexListeners.clear();
if (this.shouldReconnect) {
this.scheduleReconnect();

View File

@@ -15,7 +15,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments } from '../db/schema';
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
import type { WsToolCall } from '../../shared/api-types';
// ---------------------------------------------------------------------------
@@ -31,6 +31,7 @@ const TABLE_REGISTRY = {
timelineEvents,
// Alias: the backend sends "timelines" as the table name
timelines: timelineEvents,
projectFolderFiles,
} as const;
type TableName = keyof typeof TABLE_REGISTRY;
@@ -187,6 +188,12 @@ export class DrizzleExecutor {
return this.handleReadFileContent(payload);
case 'get_file_metadata':
return this.handleGetFileMetadata(payload);
case 'read_project_folder_manifest':
return this.handleReadProjectFolderManifest(payload);
case 'read_project_folder_file':
return this.handleReadProjectFolderFile(payload);
case 'list_projects_with_folder_manifests':
return this.handleListProjectsWithFolderManifests();
default:
throw new ExecutorError(`Unknown action: "${action as string}"`);
}
@@ -436,4 +443,143 @@ export class DrizzleExecutor {
modifiedAt: stat.mtime.toISOString(),
};
}
// -------------------------------------------------------------------------
// Project folder handlers
// -------------------------------------------------------------------------
private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
const { projectId } = (payload.data ?? {}) as { projectId: string };
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
const files = getDb()
.select({
relPath: projectFolderFiles.relativePath,
kind: projectFolderFiles.kind,
summary: projectFolderFiles.summary,
mtimeMs: projectFolderFiles.mtimeMs,
})
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, projectId))
.all();
// On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
// for the next call.
if (proj.folderLastScanStatus !== 'scanning') {
void import('../files/scanner')
.then(async ({ scanFolder }) => {
const delta = await scanFolder(projectId, proj.folderPath!);
if (
delta.newFiles.length > 0 ||
delta.changedFiles.length > 0 ||
delta.deletedRelPaths.length > 0
) {
const { startIndexSession } = await import('../files/indexer');
// eslint-disable-next-line @typescript-eslint/no-empty-function
void startIndexSession(projectId, () => {});
}
})
// eslint-disable-next-line @typescript-eslint/no-empty-function
.catch(() => {});
}
return {
folderPath: proj.folderPath,
lastScannedAt: proj.folderLastScannedAt,
files,
};
}
private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
const { projectId, relativePath, offset, length } = (payload.data ?? {}) as {
projectId: string;
relativePath: string;
offset?: number;
length?: number;
};
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
throw new ExecutorError('Access denied');
}
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj?.folderPath) return { content: '', kind: 'missing', totalSize: 0 };
const abs = path.join(proj.folderPath, relativePath);
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
throw new ExecutorError('Access denied');
}
try {
const stat = await fs.promises.stat(abs);
const ext = path.extname(relativePath).toLowerCase();
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
const buf = await fs.promises.readFile(abs);
return { content: buf.toString('base64'), kind: 'image', totalSize: stat.size };
}
// PDF + DOCX: return full base64; backend extracts text + slices.
if (ext === '.pdf' || ext === '.docx') {
const buf = await fs.promises.readFile(abs);
return {
content: buf.toString('base64'),
kind: ext === '.pdf' ? 'pdf' : 'docx',
totalSize: stat.size,
};
}
// Text: slice at offset/length on Electron side to keep WS payload small.
const start = Math.max(0, offset ?? 0);
const want = Math.max(1, Math.min(length ?? MAX_READ_SIZE_BYTES, MAX_READ_SIZE_BYTES));
const end = Math.min(start + want, stat.size);
const len = Math.max(0, end - start);
if (len === 0) {
return { content: '', kind: 'text', totalSize: stat.size };
}
const buf = Buffer.alloc(len);
const fd = await fs.promises.open(abs, 'r');
try {
await fd.read(buf, 0, len, start);
} finally {
await fd.close();
}
return { content: buf.toString('utf8'), kind: 'text', totalSize: stat.size };
} catch {
return { content: '', kind: 'error', totalSize: 0 };
}
}
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
const projs = getDb()
.select()
.from(projects)
.where(sql`${projects.folderPath} IS NOT NULL`)
.all();
const out: Array<unknown> = [];
for (const p of projs) {
const files = getDb()
.select({
relPath: projectFolderFiles.relativePath,
kind: projectFolderFiles.kind,
summary: projectFolderFiles.summary,
mtimeMs: projectFolderFiles.mtimeMs,
})
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, p.id))
.all();
out.push({
projectId: p.id,
projectName: p.name,
folderPath: p.folderPath,
lastScannedAt: p.folderLastScannedAt,
files,
});
}
return { projects: out };
}
}

View File

@@ -0,0 +1,16 @@
CREATE TABLE `project_folder_files` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`relative_path` text NOT NULL,
`ext` text NOT NULL,
`kind` text NOT NULL,
`size_bytes` integer NOT NULL,
`mtime_ms` integer NOT NULL,
`summary` text,
`summary_updated_at` integer
);
--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_path` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_last_scanned_at` integer;--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_last_scan_status` text DEFAULT 'idle';--> statement-breakpoint
ALTER TABLE `projects` ADD `folder_total_files` integer DEFAULT 0 NOT NULL;

View File

@@ -0,0 +1,934 @@
{
"version": "6",
"dialect": "sqlite",
"id": "db432653-ac1d-40f4-b7eb-216d054ae191",
"prevId": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
"tables": {
"agent_run_actions": {
"name": "agent_run_actions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"verb": {
"name": "verb",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entity_title": {
"name": "entity_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"agent_runs": {
"name": "agent_runs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"clients": {
"name": "clients",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"industry": {
"name": "industry",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_edits": {
"name": "note_edits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"anchor_before": {
"name": "anchor_before",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"anchor_text": {
"name": "anchor_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"proposed_content": {
"name": "proposed_content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"run_id": {
"name": "run_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reasoning": {
"name": "reasoning",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"resolved_at": {
"name": "resolved_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_summary_updated_at": {
"name": "ai_summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_folder_files": {
"name": "project_folder_files",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"relative_path": {
"name": "relative_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ext": {
"name": "ext",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mtime_ms": {
"name": "mtime_ms",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"summary_updated_at": {
"name": "summary_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"ai_summary": {
"name": "ai_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"folder_path": {
"name": "folder_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"folder_last_scanned_at": {
"name": "folder_last_scanned_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"folder_last_scan_status": {
"name": "folder_last_scan_status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'idle'"
},
"folder_total_files": {
"name": "folder_total_files",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_attachments": {
"name": "task_attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"size_bytes": {
"name": "size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stored_path": {
"name": "stored_path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_brief_chats": {
"name": "task_brief_chats",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_error": {
"name": "is_error",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_briefings": {
"name": "task_briefings",
"columns": {
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"briefing_markdown": {
"name": "briefing_markdown",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"canvas_draft": {
"name": "canvas_draft",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"canvas_kind": {
"name": "canvas_kind",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"citations": {
"name": "citations",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_task_hash": {
"name": "source_task_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_at": {
"name": "generated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_version": {
"name": "model_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"task_comments": {
"name": "task_comments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tasks": {
"name": "tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'todo'"
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'medium'"
},
"assignee": {
"name": "assignee",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"due_date": {
"name": "due_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"estimate": {
"name": "estimate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_event_dependencies": {
"name": "timeline_event_dependencies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"from_event_id": {
"name": "from_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"to_event_id": {
"name": "to_event_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"timeline_events": {
"name": "timeline_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_date": {
"name": "end_date",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'milestone'"
},
"is_completed": {
"name": "is_completed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"is_ai_suggested": {
"name": "is_ai_suggested",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1778238659431,
"tag": "0004_right_alex_power",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1778579196669,
"tag": "0005_slim_baron_strucker",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
aiSummary: text('ai_summary'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
folderPath: text('folder_path'),
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
folderLastScanStatus: text('folder_last_scan_status', {
enum: ['idle', 'scanning', 'error'],
}).default('idle'),
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
});
export const tasks = sqliteTable('tasks', {
@@ -64,6 +70,21 @@ export const notes = sqliteTable('notes', {
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
});
export const projectFolderFiles = sqliteTable('project_folder_files', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
relativePath: text('relative_path').notNull(),
ext: text('ext').notNull(),
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
summary: text('summary'),
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
});
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
export const noteEdits = sqliteTable('note_edits', {
id: text('id').primaryKey(),
noteId: text('note_id').notNull(),

View File

@@ -0,0 +1,21 @@
/** File-type whitelists & size caps for project folder indexing. */
export const TEXT_EXTS = new Set([
'.md', '.txt', '.rst', '.adoc',
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
'.html', '.htm', '.xml',
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
'.css', '.scss', '.sass',
'.sql',
]);
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
export const PDF_EXTS = new Set(['.pdf']);
export const DOCX_EXTS = new Set(['.docx']);
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
export const INDEX_BATCH_SIZE = 5;

View File

@@ -0,0 +1,27 @@
// adiuvAI/src/main/files/daily-rescan.ts
import { getDb } from '../db';
import { projects } from '../db/schema';
import { sql, and, isNotNull } from 'drizzle-orm';
import { startIndexSession } from './indexer';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export async function runDailyRescan(): Promise<void> {
const cutoff = Date.now() - ONE_DAY_MS;
const stale = getDb()
.select()
.from(projects)
.where(
and(
isNotNull(projects.folderPath),
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
),
)
.all();
for (const p of stale) {
if (p.folderLastScanStatus === 'scanning') continue;
// Fire-and-forget; no UI listener.
// eslint-disable-next-line @typescript-eslint/no-empty-function
void startIndexSession(p.id, () => {});
}
}

222
src/main/files/indexer.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* Folder index session orchestrator.
*
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
* returned summaries to projectFolderFiles, drives progress callbacks.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { getDb } from '../db';
import { projects, projectFolderFiles } from '../db/schema';
import { eq, and } from 'drizzle-orm';
import { scanFolder, type ScannedFile } from './scanner';
import { INDEX_BATCH_SIZE } from './constants';
import { getBackendClient } from '../api/backend-client';
export interface IndexProgress {
sessionId: string;
processed: number;
total: number;
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
error?: string;
}
export type ProgressListener = (p: IndexProgress) => void;
async function readForIndex(
folderPath: string,
f: ScannedFile,
): Promise<{ content: string; mime?: string }> {
const abs = path.join(folderPath, f.relativePath);
if (f.kind === 'image') {
const buf = await readFile(abs);
const ext = f.ext.toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
return { content: buf.toString('base64'), mime };
}
if (f.kind === 'text') {
return { content: await readFile(abs, 'utf-8') };
}
// pdf / docx: read as binary, base64. Server is responsible for extraction.
const buf = await readFile(abs);
return { content: buf.toString('base64') };
}
export async function startIndexSession(
projectId: string,
onProgress: ProgressListener,
): Promise<{ sessionId: string; cancel: () => void }> {
const sessionId = randomUUID();
const db = getDb();
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
if (!proj || !proj.folderPath) {
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
db.update(projects)
.set({ folderLastScanStatus: 'scanning' })
.where(eq(projects.id, projectId))
.run();
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
const delta = await scanFolder(projectId, proj.folderPath);
// Filter out 'skipped' files — they are too large to index and must not be sent
const toIndex = [
...delta.newFiles.filter((f) => f.kind !== 'skipped'),
...delta.changedFiles.filter((f) => f.kind !== 'skipped'),
];
const total = toIndex.length;
for (const rel of delta.deletedRelPaths) {
db.delete(projectFolderFiles)
.where(
and(
eq(projectFolderFiles.projectId, projectId),
eq(projectFolderFiles.relativePath, rel),
),
)
.run();
}
if (total === 0) {
db.update(projects)
.set({
folderLastScanStatus: 'idle',
folderLastScannedAt: Date.now(),
folderTotalFiles: delta.unchangedCount,
})
.where(eq(projects.id, projectId))
.run();
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
const backend = getBackendClient();
let processed = 0;
let cancelled = false;
const finalize = (status: IndexProgress['status'], error?: string): void => {
db.update(projects)
.set({
folderLastScanStatus:
status === 'completed' || status === 'cancelled' ? 'idle' : 'error',
folderLastScannedAt: Date.now(),
folderTotalFiles: delta.unchangedCount + processed,
})
.where(eq(projects.id, projectId))
.run();
onProgress({ sessionId, processed, total, status, error });
};
backend.registerIndexSession(sessionId, {
onFileResult: ({ relPath, summary, error }) => {
if (error) return;
const f = toIndex.find((x) => x.relativePath === relPath);
if (!f) return;
const now = Date.now();
// SELECT-then-INSERT-or-UPDATE: no unique index on (projectId, relativePath)
const existing = db
.select()
.from(projectFolderFiles)
.where(
and(
eq(projectFolderFiles.projectId, projectId),
eq(projectFolderFiles.relativePath, f.relativePath),
),
)
.get();
if (existing) {
db.update(projectFolderFiles)
.set({
mtimeMs: f.mtimeMs,
sizeBytes: f.sizeBytes,
kind: f.kind,
summary: summary ?? null,
summaryUpdatedAt: now,
})
.where(eq(projectFolderFiles.id, existing.id))
.run();
} else {
db.insert(projectFolderFiles)
.values({
id: randomUUID(),
projectId,
relativePath: f.relativePath,
ext: f.ext,
kind: f.kind,
sizeBytes: f.sizeBytes,
mtimeMs: f.mtimeMs,
summary: summary ?? null,
summaryUpdatedAt: now,
})
.run();
}
},
onProgress: ({ processed: p, total: t }) => {
processed = p;
onProgress({ sessionId, processed: p, total: t, status: 'scanning' });
},
onDone: (status) => {
finalize(
status === 'completed'
? 'completed'
: status === 'cancelled'
? 'cancelled'
: status === 'quota_exceeded'
? 'quota_exceeded'
: 'error',
);
},
});
try {
backend.sendIndexSessionStart(sessionId, projectId, total);
} catch (err) {
finalize('error', err instanceof Error ? err.message : 'WS send failed');
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
// Send batches (skipped files already excluded from toIndex)
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
if (cancelled) break;
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
const payload = await Promise.all(
batch.map(async (f) => {
const { content, mime } = await readForIndex(proj.folderPath!, f);
return {
relPath: f.relativePath,
kind: f.kind as 'text' | 'image' | 'pdf' | 'docx',
content,
ext: f.ext,
mime,
sizeBytes: f.sizeBytes,
mtimeMs: f.mtimeMs,
};
}),
);
try {
backend.sendIndexFileBatch(sessionId, payload);
} catch (err) {
finalize('error', err instanceof Error ? err.message : 'WS send failed');
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { sessionId, cancel: () => {} };
}
}
const cancel = (): void => {
cancelled = true;
backend.sendIndexSessionCancel(sessionId);
};
return { sessionId, cancel };
}

95
src/main/files/scanner.ts Normal file
View File

@@ -0,0 +1,95 @@
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../db';
import { projectFolderFiles } from '../db/schema';
import { eq } from 'drizzle-orm';
import {
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
} from './constants';
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
export interface ScannedFile {
relativePath: string;
ext: string;
kind: FileKind;
sizeBytes: number;
mtimeMs: number;
}
export interface ScanDelta {
newFiles: ScannedFile[];
changedFiles: ScannedFile[];
unchangedCount: number;
deletedRelPaths: string[];
}
function classify(ext: string, sizeBytes: number): FileKind | null {
const e = ext.toLowerCase();
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
return null; // not indexable
}
async function walk(root: string): Promise<ScannedFile[]> {
const out: ScannedFile[] = [];
async function recurse(dir: string) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // permission denied — skip silently
}
for (const e of entries) {
if (e.name.startsWith('.')) continue; // skip dot dirs / files
if (e.name === 'node_modules') continue; // common noise
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await recurse(full);
} else if (e.isFile()) {
let s;
try { s = await stat(full); } catch { continue; }
const ext = path.extname(e.name);
const kind = classify(ext, s.size);
if (kind === null) continue;
out.push({
relativePath: path.relative(root, full),
ext,
kind,
sizeBytes: s.size,
mtimeMs: Math.floor(s.mtimeMs),
});
}
}
}
await recurse(root);
return out;
}
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
const scanned = await walk(folderPath);
const existing = getDb()
.select()
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, projectId))
.all();
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
const newFiles: ScannedFile[] = [];
const changedFiles: ScannedFile[] = [];
let unchanged = 0;
for (const f of scanned) {
const prev = existingMap.get(f.relativePath);
if (!prev) newFiles.push(f);
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
else unchanged++;
existingMap.delete(f.relativePath);
}
const deletedRelPaths = Array.from(existingMap.keys());
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
}

View File

@@ -10,6 +10,7 @@ import { getStore } from './store';
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
import { backfillNoteSummaries } from './db/notes-backfill';
import { runDailyRescan } from './files/daily-rescan';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -134,6 +135,8 @@ app.on('ready', () => {
startBriefScheduler();
startAgentScheduler();
// Delay so WS connection is likely up before triggering rescans
setTimeout(() => { void runDailyRescan(); }, 10_000);
});
// Clean up the persistent WS and backup timers before the app exits

View File

@@ -17,6 +17,7 @@ import { orchestrate, orchestrateFloating, orchestrateTaskBriefResearch, dailyBr
import { getAuthManager, AuthError } from '../auth/auth-manager';
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
import type { TRPCContext } from '../ipc';
import { projectFoldersRouter } from './projectFolders';
const t = initTRPC.context<TRPCContext>().create();
@@ -1854,6 +1855,7 @@ export const appRouter = router({
auth: authRouter,
agent: agentRouter,
memory: memoryRouter,
projectFolders: projectFoldersRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,128 @@
// adiuvAI/src/main/router/projectFolders.ts
import { TRPCError, initTRPC } from '@trpc/server';
import { z } from 'zod';
import { dialog } from 'electron';
import { eq } from 'drizzle-orm';
import { getDb } from '../db';
import { projects, projectFolderFiles } from '../db/schema';
import { startIndexSession, type IndexProgress } from '../files/indexer';
import { scanFolder } from '../files/scanner';
import { getBackendClient, QuotaError } from '../api/backend-client';
import type { TRPCContext } from '../ipc';
const t = initTRPC.context<TRPCContext>().create();
const router = t.router;
const publicProcedure = t.procedure;
// In-memory map of active sessions per projectId so we can cancel
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
export const projectFoldersRouter = router({
chooseFolder: publicProcedure.mutation(async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
}),
link: publicProcedure
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
.mutation(({ input }) => {
const db = getDb();
db.update(projects)
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
.where(eq(projects.id, input.projectId))
.run();
return { ok: true };
}),
unlink: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(({ input }) => {
const db = getDb();
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
db.update(projects)
.set({
folderPath: null,
folderLastScannedAt: null,
folderLastScanStatus: 'idle',
folderTotalFiles: 0,
})
.where(eq(projects.id, input.projectId))
.run();
return { ok: true };
}),
startScan: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input }) => {
const db = getDb();
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
if (!proj?.folderPath) throw new Error('No folder linked');
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
// Pre-flight: walk folder to estimate indexable file count, then ask the
// backend whether the user's tier allows proceeding.
const delta = await scanFolder(input.projectId, proj.folderPath);
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
try {
await getBackendClient().checkFolderQuota(estimated);
} catch (err) {
if (err instanceof QuotaError) {
// Encode reason + backend message so the renderer can produce a
// localised toast without an extra RPC call.
throw new TRPCError({
code: 'FORBIDDEN',
message: `QUOTA:${err.reason}:${err.message}`,
});
}
// Network / auth errors: propagate as-is so the renderer shows a
// generic error toast rather than silently swallowing the problem.
throw err;
}
const session = await startIndexSession(input.projectId, (p) => {
const entry = _active.get(input.projectId);
if (entry) entry.lastProgress = p;
if (
p.status === 'completed' ||
p.status === 'cancelled' ||
p.status === 'quota_exceeded' ||
p.status === 'error'
) {
_active.delete(input.projectId);
}
});
_active.set(input.projectId, {
cancel: session.cancel,
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
});
return { sessionId: session.sessionId };
}),
cancelScan: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(({ input }) => {
const entry = _active.get(input.projectId);
if (entry) entry.cancel();
return { ok: true };
}),
getStatus: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(({ input }) => {
const entry = _active.get(input.projectId);
return entry?.lastProgress ?? null;
}),
listFiles: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(({ input }) => {
return getDb()
.select()
.from(projectFolderFiles)
.where(eq(projectFolderFiles.projectId, input.projectId))
.orderBy(projectFolderFiles.relativePath)
.all();
}),
});

View File

@@ -26,6 +26,8 @@ import { useTimelineHistory } from '@/hooks/useTimelineHistory';
import type { EventSnapshot } from '@/components/timeline/history-types';
import { cn } from '@/lib/utils';
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
import { FolderChip } from './folder/FolderChip';
import { FilesSection } from './folder/FilesSection';
type ProjectDetailProps = {
projectId: string;
@@ -46,11 +48,13 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const timelineRef = useRef<HTMLDivElement>(null);
const tasksRef = useRef<HTMLDivElement>(null);
const notesRef = useRef<HTMLDivElement>(null);
const filesRef = useRef<HTMLDivElement>(null);
const sectionRefs: Record<SectionId, React.RefObject<HTMLDivElement | null>> = useMemo(() => ({
overview: summaryRef,
timeline: timelineRef,
tasks: tasksRef,
notes: notesRef,
files: filesRef,
}), []);
const didInitialScroll = useRef(false);
@@ -58,6 +62,11 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const { registerSection, unregisterSection } = useFloatingChat();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
{ projectId },
{ refetchInterval: (query) => query.state.data?.status === 'scanning' ? 1000 : false },
);
const {
historyOpRef,
pendingCreatePayloadRef,
@@ -441,22 +450,56 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
</Breadcrumb>
</div>
)}
<h1
className={cn(
'font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
)}
>
{projectName}
{projectName && subtitle && (
compact
? <span className="text-muted-foreground/60 font-normal"> · </span>
: <br />
)}
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
{subtitle}
</span>
</h1>
<div className="flex items-start gap-4">
<h1
className={cn(
'flex-1 min-w-0 font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
)}
>
{projectName}
{projectName && subtitle && (
compact
? <span className="text-muted-foreground/60 font-normal"> · </span>
: <br />
)}
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
{subtitle}
</span>
</h1>
<div
className={cn(
'shrink-0 overflow-hidden transition-all duration-200 ease-out',
compact ? 'max-w-0 max-h-0 opacity-0' : 'max-w-xs max-h-8 opacity-100',
)}
>
<FolderChip
projectId={project.id}
folderPath={project.folderPath ?? null}
totalFiles={project.folderTotalFiles ?? 0}
lastScannedAt={project.folderLastScannedAt ?? null}
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
scanProgress={scanStatus && scanStatus.status === 'scanning'
? { processed: scanStatus.processed, total: scanStatus.total }
: null}
onClick={() => {
const el = scrollRef.current;
const ref = filesRef.current;
if (el && ref) {
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const sectionTop = ref.getBoundingClientRect().top;
const containerTop = el.getBoundingClientRect().top;
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
void navigate({
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: 'files' }),
replace: true,
});
}}
/>
</div>
</div>
</div>
</div>
@@ -613,6 +656,22 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
)}
</section>
{/* Files section */}
<section
ref={filesRef}
data-section="files"
className="flex flex-col gap-4 pb-16"
>
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.folder.title')}</h1>
<FilesSection
projectId={project.id}
folderPath={project.folderPath ?? null}
totalFiles={project.folderTotalFiles ?? 0}
lastScannedAt={project.folderLastScannedAt ?? null}
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
/>
</section>
</div>
</div>
);

View File

@@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
export type SectionId = typeof SECTIONS[number];
interface ProjectTabBarProps {
@@ -83,6 +83,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
timeline: t('projects.projectTimeline'),
tasks: t('projects.tasks'),
notes: t('projects.notes'),
files: t('projects.folder.title'),
};
return (

View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { addMonths, startOfMonth, format } from 'date-fns';
import { Sparkles } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
} from '@/components/ui/empty';
import { useNotify } from '@/hooks/useNotify';
import { usePlatform } from '@/lib/platform';
import { FolderLinkCard } from './FolderLinkCard';
import { FolderFileList } from './FolderFileList';
import { FolderUnlinkDialog } from './FolderUnlinkDialog';
interface FilesSectionProps {
projectId: string;
folderPath: string | null;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
}
export function FilesSection({
projectId,
folderPath,
totalFiles,
lastScannedAt,
scanStatus,
}: FilesSectionProps) {
const { t } = useTranslation();
const { notify, notifyError } = useNotify();
const platform = usePlatform();
const [unlinkOpen, setUnlinkOpen] = useState(false);
const utils = trpc.useUtils();
const chooseFolder = trpc.projectFolders.chooseFolder.useMutation();
const link = trpc.projectFolders.link.useMutation({
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
onError: (err) => notifyError('errors.error', err),
});
const startScan = trpc.projectFolders.startScan.useMutation();
/** Parse a QUOTA error message from the tRPC FORBIDDEN payload. */
function handleScanError(err: { message?: string }): void {
const msg = err.message ?? '';
if (msg.startsWith('QUOTA:max_files:')) {
// Backend message format: "Folder has X files; tier 'free' allows max Y."
// Extract tier and max-count to pass to the i18n key.
const detail = msg.slice('QUOTA:max_files:'.length);
const tierMatch = detail.match(/tier '([^']+)'/);
const countMatch = detail.match(/allows max (\d+)/);
const tier = tierMatch?.[1] ?? 'your';
const count = countMatch ? parseInt(countMatch[1], 10) : 0;
notify('error', 'projects.folder.errors.tooBig', { values: { tier, count } });
return;
}
if (msg.startsWith('QUOTA:monthly_tokens:')) {
// Compute first day of next month as the reset date.
const resetDate = format(startOfMonth(addMonths(new Date(), 1)), 'PP');
notify('error', 'projects.folder.errors.monthlyExhausted', { values: { date: resetDate } });
return;
}
notifyError('errors.error', err);
}
const handleChoose = async () => {
const chosen = await chooseFolder.mutateAsync();
if (chosen) {
await link.mutateAsync({ projectId, folderPath: chosen });
// Kick first scan (fire-and-forget — progress shown via getStatus polling).
// Quota errors are caught here so we can show localised toasts.
startScan.mutate({ projectId }, { onError: handleScanError });
}
};
if (!platform.isElectron) {
return (
<div className="text-sm text-muted-foreground p-6 text-center">
{t('projects.folder.webOnlyTooltip')}
</div>
);
}
if (!folderPath) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia>
<Sparkles />
</EmptyMedia>
<EmptyTitle>{t('projects.folder.empty.title')}</EmptyTitle>
<EmptyDescription>{t('projects.folder.empty.description')}</EmptyDescription>
</EmptyHeader>
<Button
onClick={handleChoose}
disabled={chooseFolder.isPending || link.isPending}
>
{t('projects.folder.empty.cta')}
</Button>
</Empty>
);
}
return (
<div className="space-y-4">
<FolderLinkCard
projectId={projectId}
folderPath={folderPath}
totalFiles={totalFiles}
lastScannedAt={lastScannedAt}
scanStatus={scanStatus}
onUnlinkRequested={() => setUnlinkOpen(true)}
/>
<FolderFileList projectId={projectId} />
<FolderUnlinkDialog
projectId={projectId}
open={unlinkOpen}
onOpenChange={setUnlinkOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next';
import { Folder, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
interface FolderChipProps {
projectId: string;
folderPath: string | null;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
scanProgress?: { processed: number; total: number } | null;
onClick: () => void;
}
export function FolderChip({
folderPath,
totalFiles,
lastScannedAt,
scanStatus,
scanProgress,
onClick,
}: FolderChipProps) {
const { t } = useTranslation();
if (!folderPath) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="h-3 w-3" />
{t('projects.folder.linkCta')}
</button>
);
}
if (scanStatus === 'scanning' && scanProgress) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100"
>
<Folder className="h-3 w-3 animate-pulse" />
{t('projects.folder.scanning', {
processed: scanProgress.processed,
total: scanProgress.total,
})}
</button>
);
}
if (scanStatus === 'error') {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200"
>
<Folder className="h-3 w-3" />
{t('projects.folder.scanFailed')}
</button>
);
}
const relative = lastScannedAt
? formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true })
: '—';
return (
<button
onClick={onClick}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium',
'bg-[#fbc881]/20 hover:bg-[#fbc881]/30 transition-colors',
)}
>
<Folder className="h-3 w-3" />
<span>{t('projects.folder.filesCount', { count: totalFiles })}</span>
<span className="opacity-60">·</span>
<span className="opacity-70">{relative}</span>
</button>
);
}

View File

@@ -0,0 +1,70 @@
import { useState, useMemo } from 'react';
import { trpc } from '@/lib/trpc';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface FolderFileListProps {
projectId: string;
}
type Filter = 'all' | 'text' | 'image' | 'pdf' | 'docx';
const FILTERS: Filter[] = ['all', 'text', 'image', 'pdf', 'docx'];
export function FolderFileList({ projectId }: FolderFileListProps) {
const [filter, setFilter] = useState<Filter>('all');
const { data, isLoading } = trpc.projectFolders.listFiles.useQuery({ projectId });
const items = useMemo(() => {
if (!data) return [];
if (filter === 'all') return data;
return data.filter((f) => f.kind === filter);
}, [data, filter]);
if (isLoading) {
return (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-12" />
))}
</div>
);
}
return (
<div>
<div className="flex gap-2 mb-3 text-xs">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-2.5 py-1 rounded-full border border-border',
filter === f
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground',
)}
>
{f}
</button>
))}
</div>
<ul className="space-y-1.5">
{items.map((f) => (
<li
key={f.id}
className={cn(
'rounded-md px-3 py-2 border border-border bg-background/50',
f.kind === 'skipped' && 'opacity-50',
)}
>
<div className="font-mono text-xs">{f.relativePath}</div>
{f.summary && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{f.summary}</div>
)}
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Folder } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { formatDistanceToNow } from 'date-fns';
interface FolderLinkCardProps {
projectId: string;
folderPath: string;
totalFiles: number;
lastScannedAt: number | null;
scanStatus: 'idle' | 'scanning' | 'error' | null;
onUnlinkRequested: () => void;
}
export function FolderLinkCard({
projectId,
folderPath,
totalFiles,
lastScannedAt,
scanStatus,
onUnlinkRequested,
}: FolderLinkCardProps) {
const { t } = useTranslation();
const { notifyError } = useNotify();
const utils = trpc.useUtils();
const startScan = trpc.projectFolders.startScan.useMutation({
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
onError: (err) => notifyError('errors.error', err),
});
return (
<div className="flex items-center gap-3">
<Folder className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
<div className="text-xs text-muted-foreground/70 mt-0.5">
{t('projects.folder.filesCount', { count: totalFiles })}
{lastScannedAt && (
<>
{' · '}
{t('projects.folder.lastScanned', {
relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }),
})}
</>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="outline"
size="sm"
disabled={scanStatus === 'scanning' || startScan.isPending}
onClick={() => startScan.mutate({ projectId })}
>
{t('projects.folder.rescan')}
</Button>
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
{t('projects.folder.unlink')}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
interface FolderUnlinkDialogProps {
projectId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function FolderUnlinkDialog({
projectId,
open,
onOpenChange,
}: FolderUnlinkDialogProps) {
const { t } = useTranslation();
const { notifyError } = useNotify();
const utils = trpc.useUtils();
const unlink = trpc.projectFolders.unlink.useMutation({
onSuccess: () => {
utils.projects.get.invalidate({ id: projectId });
utils.projectFolders.listFiles.invalidate({ projectId });
onOpenChange(false);
},
onError: (err) => notifyError('errors.error', err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('projects.folder.unlink')}</DialogTitle>
<DialogDescription>
{t('projects.deleteProjectDescription')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => unlink.mutate({ projectId })}
disabled={unlink.isPending}
>
{unlink.isPending ? t('common.deleting') : t('projects.folder.unlink')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,28 +1,29 @@
import { forwardRef } from 'react';
import { cn } from '@/lib/utils';
export function PropertyPill({
icon,
label,
value,
empty,
onClick,
}: {
export interface PropertyPillProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
icon: React.ReactNode;
label: string;
value?: React.ReactNode;
value?: string | null;
empty?: boolean;
onClick?: () => void;
}) {
return (
}
export const PropertyPill = forwardRef<HTMLButtonElement, PropertyPillProps>(
({ icon, label, value, empty, className, ...rest }, ref) => (
<button
ref={ref}
type="button"
onClick={onClick}
data-empty={empty ? 'true' : undefined}
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-ring',
empty
? 'border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground/50'
: 'border border-border/60 bg-background/60 text-foreground hover:border-ring/40',
className,
)}
{...rest}
>
<span className="flex items-center">{icon}</span>
{empty ? (
@@ -34,5 +35,6 @@ export function PropertyPill({
</>
)}
</button>
);
}
),
);
PropertyPill.displayName = 'PropertyPill';

View File

@@ -1,62 +1,24 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TZDate } from 'react-day-picker';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Folder, ArrowUp, ArrowRight, ArrowDown, Circle, Clock, CheckCircle2, Calendar as CalIcon, UserPlus, Plus, Paperclip } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { PropertyPill } from './PropertyPill';
import { InlineProjectForm } from './InlineProjectForm';
import { useFormatPrefs, formatDueDate } from '@/lib/date';
import { useTaskAttachments } from './useTaskAttachments';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
/**
* Decode a ms-epoch timestamp into Y/M/D H/M parts as observed in the given IANA timezone.
* Returns null when ms is null/undefined.
*/
function derivePartsInTz(
ms: number | null | undefined,
timezone: string,
): { year: number; month: number; day: number; hour: number; minute: number } | null {
if (ms == null) return null;
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const parts = fmt.formatToParts(new Date(ms));
const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
return {
year: get('year'),
month: get('month'),
day: get('day'),
// 24h Intl formatters in some locales render midnight as "24" — normalize to 0.
hour: get('hour') % 24,
minute: get('minute'),
};
}
import { useRovingFocus } from '@/hooks/useRovingFocus';
import { useListboxKeys } from '@/hooks/useListboxKeys';
import { DateTimeField } from '@/components/ui/datetime-field';
export type TaskFormValues = {
title: string;
@@ -90,6 +52,280 @@ const DEFAULTS: TaskFormValues = {
estimate: null,
};
// ---------------------------------------------------------------------------
// Sub-components (defined outside TaskFormDialog to avoid recreation on render)
// ---------------------------------------------------------------------------
function ProjectList({
projects,
selectedId,
onSelect,
onCreate,
onClose,
}: {
projects: { id: string; name: string }[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onCreate: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
// Item layout: [0] "+ New project", [1] "No project", [2..N+1] projects
const items = [
{ kind: 'new' as const },
{ kind: 'none' as const },
...projects.map((p) => ({ kind: 'project' as const, id: p.id, name: p.name })),
];
const listbox = useListboxKeys({
itemCount: items.length,
initialIndex: selectedId
? Math.max(0, items.findIndex((it) => it.kind === 'project' && it.id === selectedId))
: 1,
onSelect: (i) => {
const it = items[i];
if (it.kind === 'new') onCreate();
else if (it.kind === 'none') onSelect(null);
else onSelect(it.id);
},
onClose,
});
useEffect(() => {
listbox.focusIndex(listbox.activeIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const base =
'w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none flex items-center gap-1.5';
return (
<div className="p-1" role="listbox" aria-label={t('tasks.project')}>
{items.map((it, i) => {
const itemProps = listbox.getItemProps(i);
if (it.kind === 'new') {
return (
<button
key="new"
type="button"
{...itemProps}
role="option"
className={base + ' text-primary'}
onClick={() => onCreate()}
>
<Plus className="h-3.5 w-3.5" />
{t('projects.newProject')}
</button>
);
}
if (it.kind === 'none') {
return (
<button
key="none"
type="button"
{...itemProps}
role="option"
className={base}
onClick={() => onSelect(null)}
>
{t('tasks.noProject')}
</button>
);
}
return (
<button
key={it.id}
type="button"
{...itemProps}
role="option"
className={base}
onClick={() => onSelect(it.id)}
>
{it.name}
</button>
);
})}
</div>
);
}
function PriorityList({
value,
onSelect,
onClose,
}: {
value: string;
onSelect: (v: 'high' | 'medium' | 'low') => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const items = ['high', 'medium', 'low'] as const;
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
const listbox = useListboxKeys({
itemCount: items.length,
initialIndex: initial,
onSelect: (i) => onSelect(items[i]),
onClose,
});
useEffect(() => {
listbox.focusIndex(listbox.activeIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div role="listbox" aria-label={t('tasks.priority')} className="p-1">
{items.map((p, i) => (
<button
key={p}
type="button"
{...listbox.getItemProps(i)}
role="option"
aria-selected={value === p}
onClick={() => onSelect(p)}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{t(`tasks.${p}`)}
</button>
))}
</div>
);
}
function StatusList({
value,
onSelect,
onClose,
}: {
value: string;
onSelect: (v: 'todo' | 'in_progress' | 'done') => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const items = ['todo', 'in_progress', 'done'] as const;
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
const listbox = useListboxKeys({
itemCount: items.length,
initialIndex: initial,
onSelect: (i) => onSelect(items[i]),
onClose,
});
useEffect(() => {
listbox.focusIndex(listbox.activeIndex);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div role="listbox" aria-label={t('tasks.status')} className="p-1">
{items.map((s, i) => (
<button
key={s}
type="button"
{...listbox.getItemProps(i)}
role="option"
aria-selected={value === s}
onClick={() => onSelect(s)}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
</button>
))}
</div>
);
}
function AssigneesList({
known,
selected,
onToggle,
onClose,
newName,
onNewNameChange,
onAddNew,
}: {
known: string[];
selected: string[];
onToggle: (name: string) => void;
onClose: () => void;
newName: string;
onNewNameChange: (s: string) => void;
onAddNew: () => void;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const listbox = useListboxKeys({
itemCount: known.length,
initialIndex: 0,
onSelect: (i) => onToggle(known[i]),
onClose,
});
useEffect(() => {
if (known.length > 0) listbox.focusIndex(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="p-2" role="listbox" aria-multiselectable="true" aria-label={t('tasks.assignees')}>
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{known.map((name, i) => {
const isOn = selected.includes(name);
const itemProps = listbox.getItemProps(i);
return (
<button
key={name}
type="button"
{...itemProps}
ref={(el) => {
itemRefs.current[i] = el;
itemProps.ref(el);
}}
role="option"
aria-selected={isOn}
onClick={() => onToggle(name)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' && i === known.length - 1) {
e.preventDefault();
e.stopPropagation();
inputRef.current?.focus();
return;
}
itemProps.onKeyDown(e);
}}
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
>
{isOn ? '✓ ' : ' '}{name}
</button>
);
})}
</div>
<div className="border-t mt-2 pt-2 flex gap-1.5">
<Input
ref={inputRef}
placeholder={t('tasks.newAssigneeName', 'New name…')}
value={newName}
onChange={(e) => onNewNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onAddNew();
} else if (e.key === 'ArrowUp' && known.length > 0) {
e.preventDefault();
itemRefs.current[known.length - 1]?.focus();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}}
className="h-8 text-sm flex-1"
/>
<Button type="button" size="sm" onClick={onAddNew} disabled={!newName.trim()}>
{t('common.add')}
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function TaskFormDialog({
open,
onOpenChange,
@@ -102,15 +338,26 @@ export function TaskFormDialog({
const { t } = useTranslation();
const [values, setValues] = useState<TaskFormValues>({ ...DEFAULTS, ...initialValues });
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false);
const [priorityOpen, setPriorityOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
const [assigneesOpen, setAssigneesOpen] = useState(false);
const [dueOpen, setDueOpen] = useState(false);
const [creatingProject, setCreatingProject] = useState(false);
const [assigneeInput, setAssigneeInput] = useState('');
const PILL_COUNT = 5; // Project, Priority, Status, Due, Assignees
const pillsRoving = useRovingFocus({ count: PILL_COUNT, direction: 'both' });
useEffect(() => {
if (open) {
setValues({ ...DEFAULTS, ...initialValues });
setCreatingProject(false);
setAssigneeInput('');
setProjectPopoverOpen(false);
setPriorityOpen(false);
setStatusOpen(false);
setAssigneesOpen(false);
setDueOpen(false);
}
}, [open, initialValues]);
@@ -121,36 +368,19 @@ export function TaskFormDialog({
setAssigneeInput('');
}
const handleDueChange = useCallback((d: Date | undefined) => {
setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }));
}, []);
const handleDueCommit = useCallback(() => {
setDueOpen(false);
}, []);
const { data: projectsList = [] } = trpc.projects.listAll.useQuery();
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
const prefs = useFormatPrefs();
const selectedProject = projectsList.find((p) => p.id === values.projectId);
const attachments = useTaskAttachments(mode === 'edit' && taskId ? taskId : null);
// Derive H/M from current dueDate in user timezone for display in time selects.
const parts = derivePartsInTz(values.dueDate, prefs.timezone);
const dueHour = parts ? String(parts.hour).padStart(2, '0') : '';
// Snap minute display to nearest 5-minute step value if in selectable list.
const minuteStr = parts ? String(parts.minute).padStart(2, '0') : '';
const dueMinute = MINUTES.includes(minuteStr) ? minuteStr : '';
function updateDueTime(hour: string, minute: string) {
if (!values.dueDate) return;
const cur = derivePartsInTz(values.dueDate, prefs.timezone);
if (!cur) return;
const tz = new TZDate(
cur.year,
cur.month - 1,
cur.day,
parseInt(hour, 10),
parseInt(minute, 10),
0,
0,
prefs.timezone,
);
setValues((v) => ({ ...v, dueDate: tz.getTime() }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!values.title.trim()) return;
@@ -160,10 +390,13 @@ export function TaskFormDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[580px] p-0 gap-0 overflow-hidden bg-card/92 backdrop-blur-xl">
<DialogHeader className="px-5 py-3 border-b border-border/40">
<DialogTitle className="text-sm font-medium">
<DialogHeader className="px-5 pt-5 pb-2">
<DialogTitle>
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
</DialogTitle>
<DialogDescription>
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
</DialogDescription>
</DialogHeader>
<form
@@ -189,12 +422,16 @@ export function TaskFormDialog({
/>
</div>
{/* PROPERTIES section + footer go in next tasks */}
<div className="px-5 pb-3 pt-1">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
{t('tasks.properties')}
</div>
<div className="flex flex-wrap gap-1.5" data-testid="property-pills">
<div
className="flex flex-wrap gap-1.5"
data-testid="property-pills"
role="toolbar"
aria-label={t('tasks.properties')}
>
{/* Project */}
<Popover
open={projectPopoverOpen}
@@ -204,17 +441,17 @@ export function TaskFormDialog({
}}
>
<PopoverTrigger asChild>
<span>
<PropertyPill
icon={<Folder className="h-3 w-3" />}
label={t('tasks.project')}
value={selectedProject?.name ?? null}
empty={!selectedProject}
/>
</span>
<PropertyPill
{...pillsRoving.getItemProps(0)}
icon={<Folder className="h-3 w-3" />}
label={t('tasks.project')}
value={selectedProject?.name ?? null}
empty={!selectedProject}
aria-label={t('tasks.project') + (selectedProject ? `: ${selectedProject.name}` : '')}
/>
</PopoverTrigger>
<PopoverContent className="w-72 p-0 max-h-96 overflow-y-auto" align="start">
{creatingProject ? (
{projectPopoverOpen && (creatingProject ? (
<InlineProjectForm
onCancel={() => setCreatingProject(false)}
onCreated={(id) => {
@@ -224,243 +461,132 @@ export function TaskFormDialog({
}}
/>
) : (
<div className="p-1">
<button
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 flex items-center gap-1.5 text-primary"
onClick={() => setCreatingProject(true)}
>
<Plus className="h-3.5 w-3.5" />
{t('projects.newProject')}
</button>
<div className="my-1 h-px bg-border/60" />
<button
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
setValues((v) => ({ ...v, projectId: null }));
setProjectPopoverOpen(false);
}}
>
{t('tasks.noProject')}
</button>
{projectsList.map((p) => (
<button
key={p.id}
type="button"
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
setValues((v) => ({ ...v, projectId: p.id }));
setProjectPopoverOpen(false);
}}
>
{p.name}
</button>
))}
</div>
)}
<ProjectList
projects={projectsList}
selectedId={values.projectId}
onSelect={(id) => {
setValues((v) => ({ ...v, projectId: id }));
setProjectPopoverOpen(false);
}}
onCreate={() => setCreatingProject(true)}
onClose={() => setProjectPopoverOpen(false)}
/>
))}
</PopoverContent>
</Popover>
{/* Priority */}
<Popover>
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger asChild>
<span>
<PropertyPill
icon={
values.priority === 'high' ? <ArrowUp className="h-3 w-3 text-red-600" /> :
values.priority === 'low' ? <ArrowDown className="h-3 w-3 text-muted-foreground" /> :
<ArrowRight className="h-3 w-3 text-amber-600" />
}
label={t('tasks.priority')}
value={t(`tasks.${values.priority}`)}
/>
</span>
<PropertyPill
{...pillsRoving.getItemProps(1)}
icon={
values.priority === 'high' ? <ArrowUp className="h-3 w-3 text-red-600" /> :
values.priority === 'low' ? <ArrowDown className="h-3 w-3 text-muted-foreground" /> :
<ArrowRight className="h-3 w-3 text-amber-600" />
}
label={t('tasks.priority')}
value={t(`tasks.${values.priority}`)}
aria-label={t('tasks.priority') + `: ${t(`tasks.${values.priority}`)}`}
/>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{(['high', 'medium', 'low'] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setValues((v) => ({ ...v, priority: p }))}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
>
{t(`tasks.${p}`)}
</button>
))}
<PopoverContent className="w-40 p-0" align="start">
{priorityOpen && (
<PriorityList
value={values.priority}
onSelect={(p) => {
setValues((v) => ({ ...v, priority: p }));
setPriorityOpen(false);
}}
onClose={() => setPriorityOpen(false)}
/>
)}
</PopoverContent>
</Popover>
{/* Status */}
<Popover>
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<span>
<PropertyPill
icon={
values.status === 'in_progress' ? <Clock className="h-3 w-3" /> :
values.status === 'done' ? <CheckCircle2 className="h-3 w-3" /> :
<Circle className="h-3 w-3" />
}
label={t('tasks.status')}
value={t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
/>
</span>
<PropertyPill
{...pillsRoving.getItemProps(2)}
icon={
values.status === 'in_progress' ? <Clock className="h-3 w-3" /> :
values.status === 'done' ? <CheckCircle2 className="h-3 w-3" /> :
<Circle className="h-3 w-3" />
}
label={t('tasks.status')}
value={t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
aria-label={t('tasks.status') + `: ${t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}`}
/>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{(['todo', 'in_progress', 'done'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setValues((v) => ({ ...v, status: s }))}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
>
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
</button>
))}
<PopoverContent className="w-40 p-0" align="start">
{statusOpen && (
<StatusList
value={values.status}
onSelect={(s) => {
setValues((v) => ({ ...v, status: s }));
setStatusOpen(false);
}}
onClose={() => setStatusOpen(false)}
/>
)}
</PopoverContent>
</Popover>
{/* Due date */}
<Popover>
<Popover open={dueOpen} onOpenChange={setDueOpen}>
<PopoverTrigger asChild>
<span>
<PropertyPill
icon={<CalIcon className="h-3 w-3" />}
label={t('tasks.colDue')}
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
empty={!values.dueDate}
/>
</span>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={values.dueDate ? new Date(values.dueDate) : undefined}
timeZone={prefs.timezone}
onSelect={(d) => {
if (!d) {
setValues((v) => ({ ...v, dueDate: null }));
return;
}
// Preserve current H/M if any; otherwise default to 00:00 in user tz.
const cur = derivePartsInTz(values.dueDate, prefs.timezone);
const tz = new TZDate(
d.getFullYear(),
d.getMonth(),
d.getDate(),
cur?.hour ?? 0,
cur?.minute ?? 0,
0,
0,
prefs.timezone,
);
setValues((v) => ({ ...v, dueDate: tz.getTime() }));
}}
<PropertyPill
{...pillsRoving.getItemProps(3)}
icon={<CalIcon className="h-3 w-3" />}
label={t('tasks.colDue')}
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
empty={!values.dueDate}
aria-label={t('tasks.colDue')}
/>
</PopoverTrigger>
<PopoverContent className="w-auto p-3" align="start">
<DateTimeField
withTime
value={values.dueDate ? new Date(values.dueDate) : undefined}
onChange={handleDueChange}
onCommit={handleDueCommit}
aria-label={t('tasks.colDue')}
/>
<div className="border-t px-3 py-2 flex flex-col gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t('tasks.timeOptional')}
</label>
<div className="flex items-center gap-1.5">
<Select
value={dueHour}
onValueChange={(h) => updateDueTime(h, dueMinute || '00')}
disabled={!values.dueDate}
>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select
value={dueMinute}
onValueChange={(m) => updateDueTime(dueHour || '00', m)}
disabled={!values.dueDate}
>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
{values.dueDate && (dueHour !== '00' || dueMinute !== '00') && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => updateDueTime('00', '00')}
>
{t('common.clear')}
</Button>
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* Assignees */}
<Popover>
<Popover open={assigneesOpen} onOpenChange={setAssigneesOpen}>
<PopoverTrigger asChild>
<span>
<PropertyPill
icon={<UserPlus className="h-3 w-3" />}
label={t('tasks.assignees')}
value={values.assignees.length > 0 ? values.assignees.join(', ') : null}
empty={values.assignees.length === 0}
/>
</span>
<PropertyPill
{...pillsRoving.getItemProps(4)}
icon={<UserPlus className="h-3 w-3" />}
label={t('tasks.assignees')}
value={values.assignees.length > 0 ? values.assignees.join(', ') : null}
empty={values.assignees.length === 0}
aria-label={t('tasks.assignees') + (values.assignees.length > 0 ? `: ${values.assignees.join(', ')}` : '')}
/>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{knownAssignees.map((name) => (
<button
key={name}
type="button"
onClick={() => setValues((v) => ({
<PopoverContent className="w-64 p-0" align="start">
{assigneesOpen && (
<AssigneesList
known={knownAssignees}
selected={values.assignees}
onToggle={(name) =>
setValues((v) => ({
...v,
assignees: v.assignees.includes(name)
? v.assignees.filter((a) => a !== name)
: [...v.assignees, name],
}))}
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
>
{values.assignees.includes(name) ? '✓ ' : ' '}{name}
</button>
))}
</div>
<div className="border-t mt-2 pt-2 flex gap-1.5">
<Input
placeholder={t('tasks.newAssigneeName', 'New name…')}
value={assigneeInput}
onChange={(e) => setAssigneeInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewAssignee();
}
}}
className="h-8 text-sm flex-1"
}))
}
onClose={() => setAssigneesOpen(false)}
newName={assigneeInput}
onNewNameChange={setAssigneeInput}
onAddNew={addNewAssignee}
/>
<Button
type="button"
size="sm"
onClick={addNewAssignee}
disabled={!assigneeInput.trim()}
>
{t('common.add')}
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>

View File

@@ -1,14 +1,14 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatPrefs, formatDate } from '@/lib/date';
import { CalendarIcon, Check } from 'lucide-react';
import { type DateRange } from 'react-day-picker';
import { Check, X } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
@@ -21,10 +21,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { DateField } from '@/components/ui/date-field';
import { cn } from '@/lib/utils';
import type { TimelineEventType } from './ProjectTimeline';
import type { HistoryEntry } from './history-types';
@@ -35,218 +35,414 @@ interface AddEventDialogProps {
onRecordHistory?: (entry: HistoryEntry) => void;
}
interface AddedEntry {
type StagedEvent = {
id: string;
title: string;
type: TimelineEventType;
date: Date;
endDate?: Date;
type: TimelineEventType;
};
type Mode = { kind: 'add' } | { kind: 'edit'; id: string };
function newLocalId(): string {
return 'staged_' + Math.random().toString(36).slice(2, 10);
}
export function AddEventDialog({ open, onOpenChange, defaultProjectId, onRecordHistory }: AddEventDialogProps) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const { notify, notifyError } = useNotify();
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const [staged, setStaged] = useState<StagedEvent[]>([]);
const [mode, setMode] = useState<Mode>({ kind: 'add' });
const [title, setTitle] = useState('');
const [type, setType] = useState<TimelineEventType>('milestone');
const [dateRange, setDateRange] = useState<DateRange | undefined>();
const [singleDate, setSingleDate] = useState<Date | undefined>();
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const [added, setAdded] = useState<AddedEntry[]>([]);
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
const titleRef = useRef<HTMLInputElement>(null);
const closedRef = useRef(false);
const [focusedRowId, setFocusedRowId] = useState<string | null>(null);
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
const stagedListRef = useRef<HTMLUListElement>(null);
const showProjectSelect = !defaultProjectId;
const projectLocked = staged.length > 0;
const isActivity = type === 'activity';
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
enabled: showProjectSelect,
});
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const createEvent = trpc.timelineEvents.create.useMutation();
const createEvent = trpc.timelineEvents.create.useMutation({
onSuccess: (data, variables) => {
notify('success', 'toast.timeline.created');
void utils.timelineEvents.list.invalidate();
onRecordHistory?.({
kind: 'create',
id: data.id,
payload: {
id: data.id,
projectId: variables.projectId ?? null,
title: variables.title,
date: variables.date,
endDate: variables.endDate ?? null,
type: (variables.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity',
isCompleted: 0,
isAiSuggested: 0,
},
});
setAdded((prev) => [
...prev,
{
title: variables.title,
date: new Date(variables.date),
endDate: variables.endDate ? new Date(variables.endDate) : undefined,
type: variables.type as TimelineEventType,
},
]);
setTitle('');
setDateRange(undefined);
setSingleDate(undefined);
},
onError: (err) => notifyError('toast.timeline.createError', err),
});
function resetForm() {
setTitle('');
setDate(undefined);
setEndDate(undefined);
setMode({ kind: 'add' });
setTimeout(() => titleRef.current?.focus(), 0);
}
function handleClose() {
closedRef.current = true;
setTitle('');
setType('milestone');
setDateRange(undefined);
setSingleDate(undefined);
setDate(undefined);
setEndDate(undefined);
setProjectId(defaultProjectId ?? '');
setAdded([]);
setStaged([]);
setMode({ kind: 'add' });
onOpenChange(false);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const pid = defaultProjectId || projectId || undefined;
function attemptClose() {
if (staged.length === 0) {
handleClose();
return;
}
const ok = window.confirm(t('timeline.confirmCloseStaged', { count: staged.length }));
if (ok) handleClose();
}
if (isActivity) {
if (!title.trim() || !dateRange?.from) return;
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
createEvent.mutate({
title: title.trim(),
date: dateRange.from.getTime(),
endDate: hasDuration ? dateRange.to!.getTime() : undefined,
type: 'activity',
projectId: pid,
});
function formValid(): boolean {
if (!title.trim()) return false;
if (!date) return false;
if (isActivity && endDate && endDate < date) return false;
if (showProjectSelect && !projectId) return false;
return true;
}
function stageOrUpdate() {
if (!formValid() || !date) return;
const entry: StagedEvent = {
id: mode.kind === 'edit' ? mode.id : newLocalId(),
title: title.trim(),
type,
date,
endDate: isActivity ? endDate : undefined,
};
if (mode.kind === 'edit') {
setStaged((prev) => prev.map((e) => (e.id === entry.id ? entry : e)));
} else {
if (!title.trim() || !singleDate) return;
createEvent.mutate({
title: title.trim(),
date: singleDate.getTime(),
type,
projectId: pid,
});
setStaged((prev) => [...prev, entry]);
}
resetForm();
}
async function saveBatch() {
if (staged.length === 0) return;
const pid = defaultProjectId || projectId || undefined;
closedRef.current = false;
const results = await Promise.allSettled(
staged.map((e) =>
createEvent.mutateAsync({
title: e.title,
date: e.date.getTime(),
endDate: e.endDate ? e.endDate.getTime() : undefined,
type: e.type,
projectId: pid,
}),
),
);
let okCount = 0;
const failedIds = new Set<string>();
results.forEach((r, i) => {
const s = staged[i];
if (r.status === 'fulfilled') {
okCount += 1;
onRecordHistory?.({
kind: 'create',
id: r.value.id,
payload: {
id: r.value.id,
projectId: pid ?? null,
title: s.title,
date: s.date.getTime(),
endDate: s.endDate ? s.endDate.getTime() : null,
type: s.type,
isCompleted: 0,
isAiSuggested: 0,
},
});
} else {
failedIds.add(s.id);
}
});
if (closedRef.current) return;
void utils.timelineEvents.list.invalidate();
if (failedIds.size === 0) {
notify('success', 'toast.timeline.batchCreated', { count: okCount });
handleClose();
return;
}
if (okCount === 0) {
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
notifyError('toast.timeline.batchFailed', firstError?.reason);
} else {
notify('warning', 'toast.timeline.batchPartial', { ok: okCount, failed: failedIds.size });
}
setStaged((prev) => prev.filter((e) => failedIds.has(e.id)));
}
function loadRowIntoForm(row: StagedEvent) {
setTitle(row.title);
setType(row.type);
setDate(row.date);
setEndDate(row.endDate);
setMode({ kind: 'edit', id: row.id });
setFocusedRowId(null);
setTimeout(() => titleRef.current?.focus(), 0);
}
function removeRow(id: string) {
const idx = staged.findIndex((s) => s.id === id);
setStaged((prev) => prev.filter((s) => s.id !== id));
setFocusedRowId(null);
if (mode.kind === 'edit' && mode.id === id) {
setMode({ kind: 'add' });
}
setTimeout(() => {
const next = staged[idx + 1] ?? staged[idx - 1];
if (next) {
const el = rowRefs.current.get(next.id);
if (el) {
setFocusedRowId(next.id);
el.focus();
return;
}
}
titleRef.current?.focus();
}, 0);
}
function onRowKeyDown(e: React.KeyboardEvent<HTMLLIElement>, row: StagedEvent) {
const idx = staged.findIndex((s) => s.id === row.id);
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = staged[idx + 1];
if (next) {
setFocusedRowId(next.id);
rowRefs.current.get(next.id)?.focus();
} else {
setFocusedRowId(null);
titleRef.current?.focus();
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = staged[idx - 1];
if (prev) {
setFocusedRowId(prev.id);
rowRefs.current.get(prev.id)?.focus();
}
} else if (e.key === 'Enter') {
e.preventDefault();
loadRowIntoForm(row);
} else if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
removeRow(row.id);
} else if (e.key === 'Escape') {
e.preventDefault();
setFocusedRowId(null);
titleRef.current?.focus();
}
}
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
function onFormKeyDown(e: React.KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
void saveBatch();
} else if (e.key === 'Enter') {
e.preventDefault();
stageOrUpdate();
} else if (e.key === 'Escape') {
e.preventDefault();
attemptClose();
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
<DialogContent className="sm:max-w-[420px]">
<Dialog open={open} onOpenChange={(v) => { if (!v) attemptClose(); else onOpenChange(v); }}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{t('timeline.addEventTitle')}</DialogTitle>
<DialogDescription>{t('timeline.addEventDescription')}</DialogDescription>
</DialogHeader>
{added.length > 0 && (
<ScrollArea className="max-h-32">
<div className="flex flex-col gap-1.5">
{added.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate">{entry.title}</span>
<span className="ml-auto text-xs shrink-0">
{entry.type === 'milestone' ? t('timeline.typeMilestone') : entry.type === 'checkpoint' ? t('timeline.typeCheckpoint') : t('timeline.typeActivity')}
</span>
</div>
{showProjectSelect && (
<Select
value={projectId}
onValueChange={setProjectId}
disabled={projectLocked}
>
<SelectTrigger title={projectLocked ? t('timeline.projectLocked') : undefined}>
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
</SelectTrigger>
<SelectContent>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</div>
</SelectContent>
</Select>
)}
{staged.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">{t('timeline.emptyStagedHint')}</p>
) : (
<ScrollArea className="max-h-40 border rounded-md">
<ul ref={stagedListRef} className="flex flex-col" role="listbox" aria-label={t('timeline.staged', { count: staged.length })}>
{staged.map((e) => (
<li
key={e.id}
ref={(el) => {
if (el) rowRefs.current.set(e.id, el);
else rowRefs.current.delete(e.id);
}}
tabIndex={focusedRowId === e.id ? 0 : -1}
role="option"
aria-selected={focusedRowId === e.id}
onKeyDown={(ev) => onRowKeyDown(ev, e)}
onFocus={() => setFocusedRowId(e.id)}
onBlur={(ev) => {
const next = ev.relatedTarget as Node | null;
if (!next || !stagedListRef.current?.contains(next)) {
setFocusedRowId(null);
}
}}
className={cn(
'flex items-center gap-2 px-2 py-1.5 text-sm outline-none',
focusedRowId === e.id && 'bg-accent/40',
mode.kind === 'edit' && mode.id === e.id && 'ring-1 ring-primary/40',
)}
>
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate flex-1">{e.title}</span>
<span className="text-xs text-muted-foreground shrink-0">
{e.type === 'milestone'
? t('timeline.typeMilestone')
: e.type === 'checkpoint'
? t('timeline.typeCheckpoint')
: t('timeline.typeActivity')}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{e.endDate
? `${formatDate(e.date.getTime(), prefs)} ${formatDate(e.endDate.getTime(), prefs)}`
: formatDate(e.date.getTime(), prefs)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
aria-label={t('timeline.removeRow')}
tabIndex={-1}
onClick={() => removeRow(e.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</ScrollArea>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Event type selector */}
<ToggleGroup
type="single"
value={type}
onValueChange={(v) => { if (v) setType(v as TimelineEventType); }}
className="justify-start"
>
<ToggleGroupItem value="milestone" className="text-xs px-3">{t('timeline.typeMilestone')}</ToggleGroupItem>
<ToggleGroupItem value="checkpoint" className="text-xs px-3">{t('timeline.typeCheckpoint')}</ToggleGroupItem>
<ToggleGroupItem value="activity" className="text-xs px-3">{t('timeline.typeActivity')}</ToggleGroupItem>
</ToggleGroup>
<div
className={cn(
'flex flex-col gap-3 transition-opacity',
focusedRowId !== null && 'opacity-50 pointer-events-none',
)}
onKeyDown={onFormKeyDown}
>
<Tabs value={type} onValueChange={(v) => setType(v as TimelineEventType)}>
<TabsList>
<TabsTrigger value="milestone">{t('timeline.typeMilestone')}</TabsTrigger>
<TabsTrigger value="checkpoint">{t('timeline.typeCheckpoint')}</TabsTrigger>
<TabsTrigger value="activity">{t('timeline.typeActivity')}</TabsTrigger>
</TabsList>
</Tabs>
<Input
ref={titleRef}
placeholder={t('timeline.eventTitlePlaceholder')}
aria-label={t('timeline.eventTitlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
onKeyDown={(e) => {
if (
e.key === 'ArrowUp' &&
staged.length > 0 &&
(e.currentTarget.selectionStart ?? 0) === 0
) {
e.preventDefault();
const last = staged[staged.length - 1];
setFocusedRowId(last.id);
rowRefs.current.get(last.id)?.focus();
}
}}
autoFocus
/>
{/* Date picker: range for activities, single for milestone/checkpoint */}
{isActivity ? (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange?.from ? (
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
<>{formatDate(dateRange.from.getTime(), prefs)} {formatDate(dateRange.to.getTime(), prefs)}</>
) : (
formatDate(dateRange.from.getTime(), prefs)
)
) : (
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={setDateRange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<div className="flex gap-2">
<DateField
value={date}
onChange={(d) => {
setDate(d);
if (d && endDate && endDate < d) setEndDate(undefined);
}}
placeholder={t('timeline.pickStart')}
aria-label={t('timeline.pickStart')}
className="flex-1"
/>
<DateField
value={endDate}
onChange={setEndDate}
minDate={date}
placeholder={t('timeline.pickEnd')}
aria-label={t('timeline.pickEnd')}
invalidMessage={
date && endDate && endDate < date ? t('timeline.endBeforeStart') : undefined
}
className="flex-1"
/>
</div>
) : (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{singleDate ? (
formatDate(singleDate.getTime(), prefs)
) : (
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={singleDate}
onSelect={setSingleDate}
/>
</PopoverContent>
</Popover>
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickDate')}
aria-label={t('timeline.pickDate')}
/>
)}
</div>
{showProjectSelect && (
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger>
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
</SelectTrigger>
<SelectContent>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
{added.length > 0 ? t('common.done') : t('common.cancel')}
</Button>
<Button type="submit" disabled={!canSubmit || createEvent.isPending}>
{added.length > 0 ? t('timeline.addAnother') : t('common.add')}
</Button>
</DialogFooter>
</form>
<DialogFooter>
<Button type="button" variant="outline" onClick={attemptClose}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={stageOrUpdate}
disabled={!formValid()}
>
{mode.kind === 'edit' ? t('timeline.update') : t('common.add')}
</Button>
<Button
type="button"
onClick={() => void saveBatch()}
disabled={staged.length === 0 || createEvent.isPending}
>
{t('timeline.saveAll', { count: staged.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@@ -1,8 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormatPrefs, formatDate } from '@/lib/date';
import { CalendarIcon } from 'lucide-react';
import { type DateRange } from 'react-day-picker';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
@@ -14,8 +11,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { DateField } from '@/components/ui/date-field';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { TimelineEvent, TimelineEventType } from './ProjectTimeline';
import type { HistoryEntry } from './history-types';
@@ -28,11 +24,10 @@ interface EditEventDialogProps {
export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEventDialogProps) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const [title, setTitle] = useState('');
const [type, setType] = useState<TimelineEventType>('milestone');
const [dateRange, setDateRange] = useState<DateRange | undefined>();
const [singleDate, setSingleDate] = useState<Date | undefined>();
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
const pendingPrevRef = useRef<HistoryEntry | null>(null);
@@ -42,13 +37,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
if (event) {
setTitle(event.title);
setType(event.type ?? 'milestone');
const from = new Date(event.date);
setDate(new Date(event.date));
if (event.type === 'activity' && event.endDate) {
setDateRange({ from, to: new Date(event.endDate) });
setSingleDate(undefined);
setEndDate(new Date(event.endDate));
} else {
setSingleDate(from);
setDateRange(undefined);
setEndDate(undefined);
}
}
}, [event]);
@@ -74,13 +67,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!event || !title.trim()) return;
if (!event || !title.trim() || !date) return;
if (isActivity) {
if (!dateRange?.from) return;
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
const nextDate = dateRange.from.getTime();
const nextEndDate = hasDuration ? dateRange.to!.getTime() : null;
const nextDate = date.getTime();
const nextEndDate = (endDate && endDate.getTime() !== date.getTime()) ? endDate.getTime() : null;
pendingPrevRef.current = {
kind: 'update',
id: event.id,
@@ -94,8 +85,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
};
updateEvent.mutate({ id: event.id, title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate });
} else {
if (!singleDate) return;
const nextDate = singleDate.getTime();
const nextDate = date.getTime();
pendingPrevRef.current = {
kind: 'update',
id: event.id,
@@ -111,7 +101,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
}
}
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
const canSubmit = title.trim() && date;
return (
<Dialog open={!!event} onOpenChange={onOpenChange}>
@@ -140,51 +130,30 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
/>
{isActivity ? (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange?.from ? (
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
<>{formatDate(dateRange.from.getTime(), prefs)} {formatDate(dateRange.to.getTime(), prefs)}</>
) : (
formatDate(dateRange.from.getTime(), prefs)
)
) : (
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={setDateRange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<div className="flex gap-2">
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickStart')}
aria-label={t('timeline.pickStart')}
className="flex-1"
/>
<DateField
value={endDate}
onChange={setEndDate}
minDate={date}
placeholder={t('timeline.pickEnd')}
aria-label={t('timeline.pickEnd')}
className="flex-1"
/>
</div>
) : (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
<CalendarIcon className="mr-2 h-4 w-4" />
{singleDate ? (
formatDate(singleDate.getTime(), prefs)
) : (
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={singleDate}
onSelect={setSingleDate}
/>
</PopoverContent>
</Popover>
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickDate')}
aria-label={t('timeline.pickDate')}
/>
)}
<DialogFooter>

View File

@@ -0,0 +1,304 @@
import { useEffect, useId, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CalendarIcon } from 'lucide-react';
import { useFormatPrefs, formatDate, type FormatPrefs } from '@/lib/date';
import { parseDate, type DateKeywords } from '@/lib/parseDate';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
function formatValue(d: Date, prefs: FormatPrefs, withTime: boolean): string {
const base = formatDate(d.getTime(), prefs);
if (!withTime) return base;
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
if (h === '00' && m === '00') return base;
return `${base} ${h}:${m}`;
}
function CalendarTimeBody({
value,
onChange,
onCommit,
withTime,
minDate,
onAfterPick,
}: {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
withTime: boolean;
minDate?: Date;
onAfterPick: () => void;
}) {
const dueHour = value ? String(value.getHours()).padStart(2, '0') : '';
const dueMinute = value ? String(value.getMinutes()).padStart(2, '0') : '';
function applyTime(h: string, m: string) {
if (!value) return;
const next = new Date(value);
next.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
onChange(next);
onCommit?.(next);
}
return (
<>
<Calendar
mode="single"
selected={value}
onSelect={(d) => {
if (d) {
const next = new Date(d);
if (value && withTime) {
next.setHours(value.getHours(), value.getMinutes(), 0, 0);
}
onChange(next);
onCommit?.(next);
}
onAfterPick();
}}
disabled={minDate ? { before: minDate } : undefined}
/>
{withTime && (
<div className="border-t px-3 py-2 flex items-center gap-1.5">
<Select value={dueHour} onValueChange={(h) => applyTime(h, dueMinute || '00')} disabled={!value}>
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="HH" /></SelectTrigger>
<SelectContent>
{HOURS.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select
value={dueMinute && MINUTES.includes(dueMinute) ? dueMinute : ''}
onValueChange={(m) => applyTime(dueHour || '00', m)}
disabled={!value}
>
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="MM" /></SelectTrigger>
<SelectContent>
{MINUTES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
</>
);
}
export type DateFieldProps = {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
placeholder?: string;
minDate?: Date;
autoFocus?: boolean;
invalidMessage?: string;
className?: string;
'aria-label'?: string;
id?: string;
withTime?: boolean;
flat?: boolean;
};
export function DateField({
value,
onChange,
onCommit,
placeholder,
minDate,
autoFocus,
invalidMessage,
className,
id,
withTime,
flat,
...rest
}: DateFieldProps) {
const reactId = useId();
const fieldId = id ?? reactId;
const errorId = `${fieldId}-error`;
const { t, i18n } = useTranslation();
const prefs = useFormatPrefs();
const [text, setText] = useState<string>(value ? formatValue(value, prefs, !!withTime) : '');
const [focused, setFocused] = useState(false);
const [invalid, setInvalid] = useState(false);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!focused) {
setText(value ? formatValue(value, prefs, !!withTime) : '');
setInvalid(false);
}
}, [value, focused, prefs, withTime]);
function getKeywords(): DateKeywords {
const today = i18n.t('date.keyword.today', { returnObjects: true }) as unknown;
const tomorrow = i18n.t('date.keyword.tomorrow', { returnObjects: true }) as unknown;
const yesterday = i18n.t('date.keyword.yesterday', { returnObjects: true }) as unknown;
const weekdays = i18n.t('date.keyword.weekdays', { returnObjects: true }) as unknown;
return {
today: Array.isArray(today) ? (today as string[]) : ['today'],
tomorrow: Array.isArray(tomorrow) ? (tomorrow as string[]) : ['tomorrow'],
yesterday: Array.isArray(yesterday) ? (yesterday as string[]) : ['yesterday'],
weekdays: Array.isArray(weekdays)
? (weekdays as string[][])
: [['sun'],['mon'],['tue'],['wed'],['thu'],['fri'],['sat']],
};
}
function tryParse(raw: string): Date | null {
const parsed = parseDate(raw, prefs, getKeywords());
if (!parsed) return null;
if (minDate && parsed < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) {
return null;
}
return parsed;
}
function commit(raw: string, fireCommit: boolean) {
if (!raw.trim()) {
onChange(undefined);
setInvalid(false);
return;
}
const parsed = tryParse(raw);
if (parsed) {
setInvalid(false);
onChange(parsed);
if (fireCommit) onCommit?.(parsed);
} else {
setInvalid(true);
}
}
if (flat) {
return (
<div className={cn('relative', className)}>
<Input
ref={inputRef}
id={fieldId}
autoFocus={autoFocus}
placeholder={placeholder ?? t('timeline.pickDate')}
value={text}
onChange={(e) => { setText(e.target.value); setInvalid(false); }}
onFocus={() => setFocused(true)}
onBlur={() => { setFocused(false); commit(text, false); }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(text, true);
}
}}
aria-invalid={invalid || !!invalidMessage}
aria-describedby={invalidMessage ? errorId : undefined}
aria-label={rest['aria-label']}
className={cn((invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
/>
<div className="mt-2 rounded-md border">
<CalendarTimeBody
value={value}
onChange={(d) => {
onChange(d);
if (d) {
setText(formatValue(d, prefs, !!withTime));
setInvalid(false);
}
}}
onCommit={onCommit}
withTime={!!withTime}
minDate={minDate}
onAfterPick={() => { inputRef.current?.focus(); }}
/>
</div>
{invalidMessage && (
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
)}
</div>
);
}
return (
<div className={cn('relative', className)}>
<Input
ref={inputRef}
id={fieldId}
autoFocus={autoFocus}
placeholder={placeholder ?? t('timeline.pickDate')}
value={text}
onChange={(e) => {
setText(e.target.value);
setInvalid(false);
}}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
commit(text, false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(text, true);
} else if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
}
}}
aria-invalid={invalid || !!invalidMessage}
aria-describedby={invalidMessage ? errorId : undefined}
aria-label={rest['aria-label']}
className={cn('pr-8', (invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full w-8 text-muted-foreground hover:text-foreground"
aria-label={t('timeline.pickDate')}
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
>
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<CalendarTimeBody
value={value}
onChange={(d) => {
onChange(d);
if (d) {
setText(formatValue(d, prefs, !!withTime));
setInvalid(false);
}
}}
onCommit={onCommit}
withTime={!!withTime}
minDate={minDate}
onAfterPick={() => {
setOpen(false);
inputRef.current?.focus();
}}
/>
</PopoverContent>
</Popover>
{invalidMessage && (
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,342 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useFormatPrefs, type FormatPrefs } from '@/lib/date';
import { Calendar } from '@/components/ui/calendar';
import { cn } from '@/lib/utils';
export interface DateTimeFieldProps {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
onCommit?: (d: Date) => void;
withTime?: boolean;
className?: string;
'aria-label'?: string;
}
type SegKey = 'day' | 'month' | 'year' | 'hour' | 'minute';
interface SegDef {
key: SegKey;
len: number;
min: number;
max: number;
placeholder: string;
}
const SEGS: Record<SegKey, SegDef> = {
day: { key: 'day', len: 2, min: 1, max: 31, placeholder: 'DD' },
month: { key: 'month', len: 2, min: 1, max: 12, placeholder: 'MM' },
year: { key: 'year', len: 4, min: 1900, max: 2100, placeholder: 'YYYY' },
hour: { key: 'hour', len: 2, min: 0, max: 23, placeholder: 'HH' },
minute: { key: 'minute', len: 2, min: 0, max: 59, placeholder: 'mm' },
};
type LayoutEntry = { seg: SegKey; sep: string | null };
function layoutForFormat(fmt: FormatPrefs['dateFormat']): LayoutEntry[] {
switch (fmt) {
case 'MM/dd/yyyy': return [{ seg: 'month', sep: '/' }, { seg: 'day', sep: '/' }, { seg: 'year', sep: null }];
case 'yyyy-MM-dd': return [{ seg: 'year', sep: '-' }, { seg: 'month', sep: '-' }, { seg: 'day', sep: null }];
default: return [{ seg: 'day', sep: '/' }, { seg: 'month', sep: '/' }, { seg: 'year', sep: null }];
}
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
type SegState = Record<SegKey, string>;
function fromDate(d: Date | undefined): SegState {
if (!d) return { day: '', month: '', year: '', hour: '', minute: '' };
return {
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
year: String(d.getFullYear()),
hour: String(d.getHours()).padStart(2, '0'),
minute: String(d.getMinutes()).padStart(2, '0'),
};
}
function toDate(state: SegState, withTime: boolean): Date | undefined {
const d = parseInt(state.day, 10);
const m = parseInt(state.month, 10);
const y = parseInt(state.year, 10);
if (!d || !m || !y) return undefined;
if (y < 1900 || y > 2100) return undefined;
const h = withTime && state.hour !== '' ? parseInt(state.hour, 10) : 0;
const mn = withTime && state.minute !== '' ? parseInt(state.minute, 10) : 0;
const dt = new Date(y, m - 1, d, h, mn, 0, 0);
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return undefined;
return dt;
}
export function DateTimeField({
value,
onChange,
onCommit,
withTime = false,
className,
...rest
}: DateTimeFieldProps) {
const prefs = useFormatPrefs();
const dateLayout = useMemo(() => layoutForFormat(prefs.dateFormat), [prefs.dateFormat]);
const order: SegKey[] = useMemo(() => {
const base = dateLayout.map((l) => l.seg);
return withTime ? [...base, 'hour', 'minute'] : base;
}, [dateLayout, withTime]);
const [seg, setSeg] = useState<SegState>(() => fromDate(value));
const refs = useRef<Record<SegKey, HTMLSpanElement | null>>({
day: null, month: null, year: null, hour: null, minute: null,
});
// Stable per-segment ref setters (avoid new-function-per-render).
const refSetters = useRef<Record<SegKey, (el: HTMLSpanElement | null) => void>>({
day: (el) => { refs.current.day = el; },
month: (el) => { refs.current.month = el; },
year: (el) => { refs.current.year = el; },
hour: (el) => { refs.current.hour = el; },
minute: (el) => { refs.current.minute = el; },
});
function focusSeg(key: SegKey) {
const el = refs.current[key];
if (!el) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
// Note: typing updates LOCAL state only. We deliberately don't call
// onChange on every keystroke — otherwise the parent re-renders on each
// keypress, which re-renders the (heavy) Calendar grid and the rest of
// TaskFormDialog. onChange only fires on commit (Enter) or calendar pick.
// Stable across renders: uses functional setSeg, refs, and order via ref.
const orderRef = useRef(order);
orderRef.current = order;
const withTimeRef = useRef(withTime);
withTimeRef.current = withTime;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onCommitRef = useRef(onCommit);
onCommitRef.current = onCommit;
const onSegKeyDown = useCallback((e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => {
const def = SEGS[key];
if (e.key === 'ArrowRight') {
e.preventDefault();
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
const idx = orderRef.current.indexOf(key);
const prv = orderRef.current[idx - 1];
if (prv) focusSeg(prv);
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
setSeg((prev) => {
const cur = prev[key];
const delta = e.key === 'ArrowUp' ? 1 : -1;
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
let n = base + delta;
if (n < def.min) n = def.max;
if (n > def.max) n = def.min;
return { ...prev, [key]: String(n).padStart(def.len, '0') };
});
return;
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault();
setSeg((prev) => {
if (prev[key] === '') {
const idx = orderRef.current.indexOf(key);
const prv = orderRef.current[idx - 1];
if (prv) focusSeg(prv);
return prev;
}
return { ...prev, [key]: '' };
});
return;
}
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
let advance = false;
setSeg((prev) => {
const cur = prev[key];
const incoming = cur.length >= def.len ? e.key : cur + e.key;
const numeric = parseInt(incoming, 10);
const final = numeric > def.max ? e.key : incoming;
const padded = final.padStart(Math.min(final.length, def.len), '0');
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
advance = true;
}
return { ...prev, [key]: padded };
});
if (advance) {
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
}
return;
}
if (e.key === 'Enter') {
e.preventDefault();
// Read current seg via functional updater; commit then propagate.
setSeg((prev) => {
const today = new Date();
const wt = withTimeRef.current;
const filled: SegState = {
day: prev.day || String(today.getDate()).padStart(2, '0'),
month: prev.month || String(today.getMonth() + 1).padStart(2, '0'),
year: prev.year || String(today.getFullYear()),
hour: wt ? (prev.hour || '00') : '00',
minute: wt ? (prev.minute || '00') : '00',
};
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
onChangeRef.current(dt);
onCommitRef.current?.(dt);
return fromDate(dt);
});
return;
}
if (e.key === '/' || e.key === '-' || e.key === ':' || e.key === ' ') {
e.preventDefault();
const idx = orderRef.current.indexOf(key);
const nxt = orderRef.current[idx + 1];
if (nxt) focusSeg(nxt);
return;
}
}, []);
const onCalendarSelect = useCallback((d: Date | undefined) => {
if (!d) return;
setSeg((prev) => {
const next: SegState = {
...prev,
day: String(d.getDate()).padStart(2, '0'),
month: String(d.getMonth() + 1).padStart(2, '0'),
year: String(d.getFullYear()),
};
const dt = toDate(next, withTime);
if (dt) onChange(dt);
return next;
});
}, [withTime, onChange]);
const selectedDate = toDate(seg, withTime);
const selectedMs = selectedDate ? selectedDate.getTime() : null;
const calendarEl = useMemo(
() => (
<Calendar
mode="single"
selected={selectedDate}
onSelect={onCalendarSelect}
/>
),
// selectedMs primary key; selectedDate/onCalendarSelect captured for closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedMs, onCalendarSelect],
);
return (
<div className={cn('flex flex-col gap-3', className)} aria-label={rest['aria-label']}>
<div
className="inline-flex items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus-within:ring-2 focus-within:ring-ring/30 focus-within:border-ring"
role="group"
>
{dateLayout.map(({ seg: sk, sep }) => (
<SegmentSpan
key={sk}
segKey={sk}
value={seg[sk]}
onKeyDown={onSegKeyDown}
registerRef={refSetters.current[sk]}
sep={sep}
/>
))}
{withTime && (
<>
<span className="px-1.5 text-muted-foreground/60 select-none">&nbsp;</span>
<SegmentSpan
segKey="hour"
value={seg.hour}
onKeyDown={onSegKeyDown}
registerRef={refSetters.current.hour}
sep=":"
/>
<SegmentSpan
segKey="minute"
value={seg.minute}
onKeyDown={onSegKeyDown}
registerRef={refSetters.current.minute}
sep={null}
/>
</>
)}
</div>
<div className="rounded-md border">{calendarEl}</div>
</div>
);
}
const SegmentSpan = memo(function SegmentSpan({
segKey,
value,
onKeyDown,
registerRef,
sep,
}: {
segKey: SegKey;
value: string;
onKeyDown: (e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => void;
registerRef: (el: HTMLSpanElement | null) => void;
sep: string | null;
}) {
const def = SEGS[segKey];
const isEmpty = value === '';
return (
<>
<span
ref={registerRef}
tabIndex={0}
role="spinbutton"
aria-label={def.placeholder}
aria-valuemin={def.min}
aria-valuemax={def.max}
aria-valuenow={isEmpty ? undefined : clamp(parseInt(value, 10), def.min, def.max)}
onKeyDown={(e) => onKeyDown(e, segKey)}
onFocus={(e) => {
const range = document.createRange();
range.selectNodeContents(e.currentTarget);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}}
className={cn(
'inline-block text-center outline-none rounded px-0.5 cursor-text tabular-nums',
def.len === 4 ? 'min-w-[3.5ch]' : 'min-w-[1.8ch]',
isEmpty && 'text-muted-foreground/60',
'focus:bg-accent',
)}
>
{isEmpty ? def.placeholder : value}
</span>
{sep && <span className="text-muted-foreground/70 select-none px-0.5">{sep}</span>}
</>
);
});

View File

@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
className
)}
{...props}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
export interface UseListboxKeysOptions {
itemCount: number;
initialIndex?: number;
onSelect: (index: number) => void;
onClose: () => void;
/**
* If true, focus does NOT move out of the listbox on Tab; the caller is
* expected to also call onClose so the parent can advance focus.
*/
closeOnTab?: boolean;
}
export interface ListboxItemProps {
tabIndex: number;
ref: (el: HTMLElement | null) => void;
onKeyDown: (e: ReactKeyboardEvent) => void;
onMouseEnter: () => void;
'aria-selected': boolean;
}
/**
* Listbox keyboard model used inside popovers: ↑/↓ move active item,
* Home/End jump to ends, Enter/Space selects, Esc closes, Tab closes
* (so the parent's tab order resumes from the trigger).
*/
export function useListboxKeys({
itemCount,
initialIndex = 0,
onSelect,
onClose,
closeOnTab = true,
}: UseListboxKeysOptions) {
const [active, setActive] = useState<number>(() => {
if (itemCount <= 0) return 0;
return Math.max(0, Math.min(itemCount - 1, initialIndex));
});
const refs = useRef<Array<HTMLElement | null>>([]);
const refSetters = useRef<Map<number, (el: HTMLElement | null) => void>>(new Map());
useEffect(() => {
if (active >= itemCount) setActive(Math.max(0, itemCount - 1));
}, [itemCount, active]);
const getRefSetter = useCallback((index: number) => {
let setter = refSetters.current.get(index);
if (!setter) {
setter = (el: HTMLElement | null) => {
refs.current[index] = el;
};
refSetters.current.set(index, setter);
}
return setter;
}, []);
const focusIndex = useCallback((i: number) => {
if (itemCount <= 0) return;
const clamped = Math.max(0, Math.min(itemCount - 1, i));
setActive(clamped);
const el = refs.current[clamped];
if (el) el.focus();
}, [itemCount]);
const getItemProps = useCallback(
(index: number): ListboxItemProps => ({
tabIndex: index === active ? 0 : -1,
ref: getRefSetter(index),
'aria-selected': index === active,
onMouseEnter: () => setActive(index),
onKeyDown: (e: ReactKeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
focusIndex(Math.min(index + 1, itemCount - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
focusIndex(Math.max(index - 1, 0));
} else if (e.key === 'Home') {
e.preventDefault();
focusIndex(0);
} else if (e.key === 'End') {
e.preventDefault();
focusIndex(itemCount - 1);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSelect(index);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
} else if (e.key === 'Tab' && closeOnTab) {
onClose();
}
},
}),
[active, itemCount, focusIndex, onSelect, onClose, closeOnTab, getRefSetter],
);
return { activeIndex: active, setActive, focusIndex, getItemProps };
}

View File

@@ -0,0 +1,98 @@
import { useCallback, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
export type RovingDirection = 'horizontal' | 'vertical' | 'both';
export interface UseRovingFocusOptions {
count: number;
direction?: RovingDirection;
initialIndex?: number;
loop?: boolean;
}
export interface RovingItemProps {
tabIndex: number;
onKeyDown: (e: ReactKeyboardEvent) => void;
onFocus: () => void;
ref: (el: HTMLElement | null) => void;
}
/**
* Roving tabindex helper. Group exposes a single tab stop; arrow keys
* move focus between items. Index state is internal — call `getItemProps(i)`
* for each item to wire it up.
*/
export function useRovingFocus({
count,
direction = 'both',
initialIndex = 0,
loop = false,
}: UseRovingFocusOptions) {
const [active, setActive] = useState<number>(() => {
if (count <= 0) return 0;
return Math.max(0, Math.min(count - 1, initialIndex));
});
const refs = useRef<Array<HTMLElement | null>>([]);
const refSetters = useRef<Map<number, (el: HTMLElement | null) => void>>(new Map());
const getRefSetter = useCallback((index: number) => {
let setter = refSetters.current.get(index);
if (!setter) {
setter = (el: HTMLElement | null) => {
refs.current[index] = el;
};
refSetters.current.set(index, setter);
}
return setter;
}, []);
const move = useCallback(
(next: number) => {
if (count <= 0) return;
let target = next;
if (loop) {
target = ((next % count) + count) % count;
} else {
target = Math.max(0, Math.min(count - 1, next));
}
setActive(target);
const el = refs.current[target];
if (el) el.focus();
},
[count, loop],
);
const getItemProps = useCallback(
(index: number): RovingItemProps => ({
tabIndex: index === active ? 0 : -1,
ref: getRefSetter(index),
onFocus: () => setActive(index),
onKeyDown: (e: ReactKeyboardEvent) => {
const horizontal = direction === 'horizontal' || direction === 'both';
const vertical = direction === 'vertical' || direction === 'both';
if (horizontal && e.key === 'ArrowRight') {
e.preventDefault();
move(index + 1);
} else if (horizontal && e.key === 'ArrowLeft') {
e.preventDefault();
move(index - 1);
} else if (vertical && e.key === 'ArrowDown') {
e.preventDefault();
move(index + 1);
} else if (vertical && e.key === 'ArrowUp') {
e.preventDefault();
move(index - 1);
} else if (e.key === 'Home') {
e.preventDefault();
move(0);
} else if (e.key === 'End') {
e.preventDefault();
move(count - 1);
}
},
}),
[active, count, direction, move, getRefSetter],
);
return { activeIndex: active, setActive, getItemProps };
}

View File

@@ -0,0 +1,171 @@
import type { FormatPrefs } from './date';
type ParseInput = string | null | undefined;
const RE_REL = /^\s*([+-])\s*(\d+)\s*([dwm])\s*$/i;
const RE_NUMERIC = /^(\d{1,4})[/\-.](\d{1,4})(?:[/\-.](\d{1,4}))?$/;
const RE_TIME_SUFFIX = /\s+(\d{1,2}):(\d{2})\s*$/;
function startOfDay(d: Date): Date {
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
return out;
}
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
return out;
}
function addMonths(d: Date, n: number): Date {
const out = new Date(d);
out.setMonth(out.getMonth() + n);
return out;
}
function pivotYear(twoDigit: number): number {
const now = new Date().getFullYear();
const century = Math.floor(now / 100) * 100;
const offset = now % 100;
return twoDigit > (offset + 50) ? century - 100 + twoDigit : century + twoDigit;
}
function parseNumeric(
input: string,
prefs: FormatPrefs,
base: Date,
): Date | null {
const m = input.match(RE_NUMERIC);
if (!m) return null;
let day: number, month: number, year: number;
const a = parseInt(m[1], 10);
const b = parseInt(m[2], 10);
const c = m[3] != null ? parseInt(m[3], 10) : NaN;
if (prefs.dateFormat === 'yyyy-MM-dd') {
if (m[3] == null) return null;
year = a; month = b; day = c;
} else if (prefs.dateFormat === 'MM/dd/yyyy') {
month = a; day = b;
if (m[3] == null) year = base.getFullYear();
else year = c < 100 ? pivotYear(c) : c;
} else {
// default: 'dd/MM/yyyy'
day = a; month = b;
if (m[3] == null) year = base.getFullYear();
else year = c < 100 ? pivotYear(c) : c;
}
if (month < 1 || month > 12) return null;
const result = new Date(year, month - 1, day);
if (result.getFullYear() !== year || result.getMonth() !== month - 1 || result.getDate() !== day) {
return null;
}
if (m[3] == null && result < startOfDay(base)) {
result.setFullYear(year + 1);
if (result.getDate() !== day) return null;
}
return result;
}
function parseKeyword(
word: string,
keywords: { today: string[]; tomorrow: string[]; yesterday: string[]; weekdays: string[][] },
base: Date,
): Date | null {
const w = word.trim().toLowerCase();
if (keywords.today.some((k) => k.toLowerCase() === w)) return startOfDay(base);
if (keywords.tomorrow.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), 1);
if (keywords.yesterday.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), -1);
for (let i = 0; i < keywords.weekdays.length; i++) {
if (keywords.weekdays[i].some((k) => k.toLowerCase() === w)) {
const today = startOfDay(base);
const diff = (i - today.getDay() + 7) % 7 || 7;
return addDays(today, diff);
}
}
return null;
}
export type DateKeywords = {
today: string[];
tomorrow: string[];
yesterday: string[];
/** Index 0=Sunday, 6=Saturday. Each entry: aliases (short + long). */
weekdays: string[][];
};
export function parseDate(
input: ParseInput,
prefs: FormatPrefs,
keywords: DateKeywords,
baseDate: Date = new Date(),
): Date | null {
if (!input) return null;
const raw = input.trim();
if (!raw) return null;
let datePart = raw;
let hours: number | null = null;
let minutes: number | null = null;
const tm = raw.match(RE_TIME_SUFFIX);
if (tm) {
const h = parseInt(tm[1], 10);
const m = parseInt(tm[2], 10);
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
hours = h;
minutes = m;
datePart = raw.slice(0, raw.length - tm[0].length).trim();
if (!datePart) return null;
}
const rel = datePart.match(RE_REL);
if (rel) {
const sign = rel[1] === '-' ? -1 : 1;
const n = sign * parseInt(rel[2], 10);
const unit = rel[3].toLowerCase();
const base = startOfDay(baseDate);
let result: Date;
if (unit === 'd') result = addDays(base, n);
else if (unit === 'w') result = addDays(base, n * 7);
else result = addMonths(base, n);
if (hours != null && minutes != null) result.setHours(hours, minutes, 0, 0);
return result;
}
const kw = parseKeyword(datePart, keywords, baseDate);
if (kw) {
if (hours != null && minutes != null) kw.setHours(hours, minutes, 0, 0);
return kw;
}
const num = parseNumeric(datePart, prefs, baseDate);
if (num) {
if (hours != null && minutes != null) num.setHours(hours, minutes, 0, 0);
return num;
}
return null;
}
export function parseDateRange(
input: ParseInput,
prefs: FormatPrefs,
keywords: DateKeywords,
baseDate: Date = new Date(),
): { from: Date; to?: Date } | null {
if (!input) return null;
const parts = input.split(/\s+(?:-{1,2}||to)\s+/i);
if (parts.length === 1) {
const single = parseDate(parts[0], prefs, keywords, baseDate);
return single ? { from: single } : null;
}
if (parts.length === 2) {
const from = parseDate(parts[0], prefs, keywords, baseDate);
if (!from) return null;
const to = parseDate(parts[1], prefs, keywords, from);
if (!to) return null;
return { from, to };
}
return null;
}

View File

@@ -41,6 +41,8 @@
"priority": "Priorität",
"createdDate": "Erstelldatum",
"newTask": "Neue Aufgabe",
"newTaskDescription": "Halten Sie fest, was zu tun ist. Eigenschaften unten oder später setzen.",
"editTaskDescription": "Aktualisieren Sie Details, Eigenschaften und Anhänge der Aufgabe.",
"noTasksFound": "Keine Aufgaben gefunden",
"noTasksDescription": "Erstellen Sie eine neue Aufgabe oder passen Sie Ihre Filter an.",
"taskTitle": "Aufgabentitel",
@@ -309,16 +311,34 @@
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern",
"addEventTitle": "Zeitleisten-Ereignis hinzufügen",
"addEventDescription": "Bereite ein oder mehrere Ereignisse für das Projekt vor und speichere sie gemeinsam.",
"editEventTitle": "Zeitleisten-Ereignis bearbeiten",
"eventTitlePlaceholder": "Ereignistitel",
"pickDate": "Datum auswählen",
"pickDateRange": "Datumsbereich auswählen",
"pickStart": "Startdatum",
"pickEnd": "Enddatum",
"selectProjectOptional": "Projekt auswählen (optional)",
"addAnother": "Weiteres hinzufügen",
"markAsDone": "Als erledigt markieren",
"markAsToDo": "Als zu erledigen markieren",
"editEvent": "Ereignis bearbeiten",
"deleteEvent": "Ereignis löschen"
"deleteEvent": "Ereignis löschen",
"endBeforeStart": "Ende muss nach dem Start liegen",
"dateInvalid": "Datum nicht erkannt",
"batchCreated_one": "1 Ereignis erstellt",
"batchCreated_other": "{{count}} Ereignisse erstellt",
"batchPartial": "{{ok}} erstellt, {{failed}} fehlgeschlagen",
"batchFailed": "Ereignisse konnten nicht erstellt werden",
"staged_one": "1 Ereignis vorbereitet",
"staged_other": "{{count}} Ereignisse vorbereitet",
"emptyStagedHint": "Titel eingeben, Datum festlegen, Enter drücken",
"editRow": "Bearbeiten",
"removeRow": "Entfernen",
"projectLocked": "Projekt nach dem ersten Ereignis gesperrt",
"confirmCloseStaged": "{{count}} vorbereitete Ereignisse verwerfen?",
"saveAll": "{{count}} speichern",
"update": "Aktualisieren"
},
"projects": {
"noProjectSelected": "Kein Projekt ausgewählt",
@@ -373,7 +393,30 @@
"untitledNote": "Unbenannte Notiz",
"noNotesYet": "Noch keine Notizen.",
"noNotesYetDescription": "Klicke auf \"+ Hinzufügen\", um deine erste Notiz zu erstellen.",
"overview": "Übersicht"
"overview": "Übersicht",
"folder": {
"title": "Dateien",
"linkCta": "Ordner verknüpfen",
"browse": "Durchsuchen",
"rescan": "Neu scannen",
"unlink": "Verknüpfung aufheben",
"filesCount_one": "{{count}} Datei",
"filesCount_other": "{{count}} Dateien",
"lastScanned": "zuletzt gescannt {{relative}}",
"scanning": "Indizierung {{processed}}/{{total}}",
"scanFailed": "Scan fehlgeschlagen",
"empty": {
"title": "Einen Projektordner verknüpfen",
"description": "Verbinden Sie einen lokalen Ordner, damit KI-Agenten dessen Dateien lesen können, wenn sie Fragen zu diesem Projekt beantworten.",
"cta": "Ordner auswählen…"
},
"errors": {
"tooBig": "Ordner zu groß für den {{tier}}-Plan — max. {{count}} Dateien",
"monthlyExhausted": "Monatliches Token-Budget erschöpft (zurückgesetzt am {{date}})",
"notFound": "Ordner nicht gefunden: {{path}}"
},
"webOnlyTooltip": "Ordnerverknüpfung ist in der Desktop-App verfügbar"
}
},
"agents": {
"title": "Agenten",
@@ -454,7 +497,11 @@
"updateError": "Ereignis konnte nicht aktualisiert werden",
"deleted": "Ereignis gelöscht",
"deleteError": "Ereignis konnte nicht gelöscht werden",
"historyError": "Rückgängig nicht möglich — das Ereignis wurde möglicherweise geändert."
"historyError": "Rückgängig nicht möglich — das Ereignis wurde möglicherweise geändert.",
"batchCreated_one": "1 Ereignis erstellt",
"batchCreated_other": "{{count}} Ereignisse erstellt",
"batchPartial": "{{ok}} erstellt, {{failed}} fehlgeschlagen",
"batchFailed": "Ereignisse konnten nicht erstellt werden"
},
"comment": {
"created": "Kommentar hinzugefügt",
@@ -476,5 +523,21 @@
"runStarted": "Agent-Ausführung gestartet",
"runError": "Agent konnte nicht gestartet werden"
}
},
"date": {
"keyword": {
"today": ["heute"],
"tomorrow": ["morgen"],
"yesterday": ["gestern"],
"weekdays": [
["so", "sonntag"],
["mo", "montag"],
["di", "dienstag"],
["mi", "mittwoch"],
["do", "donnerstag"],
["fr", "freitag"],
["sa", "samstag"]
]
}
}
}

View File

@@ -41,6 +41,8 @@
"priority": "Priority",
"createdDate": "Created Date",
"newTask": "New Task",
"newTaskDescription": "Capture what needs doing. Set properties below or refine later.",
"editTaskDescription": "Update the task details, properties, and attachments.",
"noTasksFound": "No tasks found",
"noTasksDescription": "Create a new task to get started or adjust your filters.",
"taskTitle": "Task title",
@@ -309,16 +311,34 @@
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"addEventTitle": "Add Timeline Event",
"addEventDescription": "Stage one or more events for the project, then save them all together.",
"editEventTitle": "Edit Timeline Event",
"eventTitlePlaceholder": "Event title",
"pickDate": "Pick a date",
"pickDateRange": "Pick a date range",
"pickStart": "Pick start date",
"pickEnd": "Pick end date",
"selectProjectOptional": "Select project (optional)",
"addAnother": "Add Another",
"markAsDone": "Mark as done",
"markAsToDo": "Mark as to-do",
"editEvent": "Edit event",
"deleteEvent": "Delete event"
"deleteEvent": "Delete event",
"endBeforeStart": "End must be after start",
"dateInvalid": "Unrecognized date",
"batchCreated_one": "1 event created",
"batchCreated_other": "{{count}} events created",
"batchPartial": "{{ok}} created, {{failed}} failed",
"batchFailed": "Could not create events",
"staged_one": "1 event staged",
"staged_other": "{{count}} events staged",
"emptyStagedHint": "Type a title, set a date, press Enter",
"editRow": "Edit",
"removeRow": "Remove",
"projectLocked": "Project locked after first event",
"confirmCloseStaged": "Discard {{count}} staged events?",
"saveAll": "Save {{count}}",
"update": "Update"
},
"projects": {
"noProjectSelected": "No project selected",
@@ -373,7 +393,30 @@
"untitledNote": "Untitled Note",
"noNotesYet": "No notes yet.",
"noNotesYetDescription": "Click \"+ Add\" to create your first note.",
"overview": "Overview"
"overview": "Overview",
"folder": {
"title": "Files",
"linkCta": "Link folder",
"browse": "Browse",
"rescan": "Rescan",
"unlink": "Unlink",
"filesCount_one": "{{count}} file",
"filesCount_other": "{{count}} files",
"lastScanned": "last scanned {{relative}}",
"scanning": "indexing {{processed}}/{{total}}",
"scanFailed": "Scan failed",
"empty": {
"title": "Link a project folder",
"description": "Connect a local folder so AI agents can read its files when answering questions about this project.",
"cta": "Choose folder…"
},
"errors": {
"tooBig": "Folder too big for {{tier}} plan — max {{count}} files",
"monthlyExhausted": "Monthly token budget exhausted (resets {{date}})",
"notFound": "Folder not found: {{path}}"
},
"webOnlyTooltip": "Folder linking available in desktop app"
}
},
"agents": {
"title": "Agents",
@@ -454,7 +497,11 @@
"updateError": "Failed to update event",
"deleted": "Event deleted",
"deleteError": "Failed to delete event",
"historyError": "Couldn't undo — event may have changed."
"historyError": "Couldn't undo — event may have changed.",
"batchCreated_one": "1 event created",
"batchCreated_other": "{{count}} events created",
"batchPartial": "{{ok}} created, {{failed}} failed",
"batchFailed": "Could not create events"
},
"comment": {
"created": "Comment added",
@@ -476,5 +523,21 @@
"runStarted": "Agent run started",
"runError": "Failed to start agent"
}
},
"date": {
"keyword": {
"today": ["today"],
"tomorrow": ["tomorrow", "tmrw"],
"yesterday": ["yesterday"],
"weekdays": [
["sun", "sunday"],
["mon", "monday"],
["tue", "tuesday"],
["wed", "wednesday"],
["thu", "thursday"],
["fri", "friday"],
["sat", "saturday"]
]
}
}
}

View File

@@ -41,6 +41,8 @@
"priority": "Prioridad",
"createdDate": "Fecha de creación",
"newTask": "Nueva tarea",
"newTaskDescription": "Anota qué hay que hacer. Define las propiedades abajo o más tarde.",
"editTaskDescription": "Actualiza los detalles, propiedades y adjuntos de la tarea.",
"noTasksFound": "No se encontraron tareas",
"noTasksDescription": "Crea una nueva tarea para empezar o ajusta tus filtros.",
"taskTitle": "Título de la tarea",
@@ -309,16 +311,34 @@
"zoomIn": "Acercar",
"zoomOut": "Alejar",
"addEventTitle": "Añadir evento de línea de tiempo",
"addEventDescription": "Añade uno o más eventos al proyecto y luego guárdalos todos a la vez.",
"editEventTitle": "Editar evento de línea de tiempo",
"eventTitlePlaceholder": "Título del evento",
"pickDate": "Selecciona una fecha",
"pickDateRange": "Selecciona un rango de fechas",
"pickStart": "Fecha de inicio",
"pickEnd": "Fecha de fin",
"selectProjectOptional": "Seleccionar proyecto (opcional)",
"addAnother": "Añadir otro",
"markAsDone": "Marcar como hecho",
"markAsToDo": "Marcar como pendiente",
"editEvent": "Editar evento",
"deleteEvent": "Eliminar evento"
"deleteEvent": "Eliminar evento",
"endBeforeStart": "El fin debe ser posterior al inicio",
"dateInvalid": "Fecha no reconocida",
"batchCreated_one": "1 evento creado",
"batchCreated_other": "{{count}} eventos creados",
"batchPartial": "{{ok}} creados, {{failed}} fallidos",
"batchFailed": "No se pudieron crear los eventos",
"staged_one": "1 evento en cola",
"staged_other": "{{count}} eventos en cola",
"emptyStagedHint": "Escribe un título, elige una fecha, pulsa Intro",
"editRow": "Editar",
"removeRow": "Quitar",
"projectLocked": "Proyecto bloqueado tras el primer evento",
"confirmCloseStaged": "¿Descartar {{count}} eventos en cola?",
"saveAll": "Guardar {{count}}",
"update": "Actualizar"
},
"projects": {
"noProjectSelected": "Ningún proyecto seleccionado",
@@ -373,7 +393,30 @@
"untitledNote": "Nota sin título",
"noNotesYet": "No hay notas.",
"noNotesYetDescription": "Haz clic en \"+ Añadir\" para crear tu primera nota.",
"overview": "Resumen"
"overview": "Resumen",
"folder": {
"title": "Archivos",
"linkCta": "Vincular carpeta",
"browse": "Explorar",
"rescan": "Reescanear",
"unlink": "Desvincular",
"filesCount_one": "{{count}} archivo",
"filesCount_other": "{{count}} archivos",
"lastScanned": "último escaneo {{relative}}",
"scanning": "indexando {{processed}}/{{total}}",
"scanFailed": "Error de escaneo",
"empty": {
"title": "Vincula una carpeta al proyecto",
"description": "Conecta una carpeta local para que los agentes de IA puedan leer sus archivos al responder preguntas sobre este proyecto.",
"cta": "Elegir carpeta…"
},
"errors": {
"tooBig": "Carpeta demasiado grande para el plan {{tier}} — máx. {{count}} archivos",
"monthlyExhausted": "Presupuesto mensual de tokens agotado (se renueva el {{date}})",
"notFound": "Carpeta no encontrada: {{path}}"
},
"webOnlyTooltip": "La vinculación de carpetas está disponible en la app de escritorio"
}
},
"agents": {
"title": "Agentes",
@@ -454,7 +497,11 @@
"updateError": "Error al actualizar el evento",
"deleted": "Evento eliminado",
"deleteError": "Error al eliminar el evento",
"historyError": "No se pudo deshacer — el evento puede haber cambiado."
"historyError": "No se pudo deshacer — el evento puede haber cambiado.",
"batchCreated_one": "1 evento creado",
"batchCreated_other": "{{count}} eventos creados",
"batchPartial": "{{ok}} creados, {{failed}} fallidos",
"batchFailed": "No se pudieron crear los eventos"
},
"comment": {
"created": "Comentario agregado",
@@ -476,5 +523,21 @@
"runStarted": "Ejecución del agente iniciada",
"runError": "Error al iniciar el agente"
}
},
"date": {
"keyword": {
"today": ["hoy"],
"tomorrow": ["mañana", "manana"],
"yesterday": ["ayer"],
"weekdays": [
["dom", "domingo"],
["lun", "lunes"],
["mar", "martes"],
["mié", "mie", "miércoles", "miercoles"],
["jue", "jueves"],
["vie", "viernes"],
["sáb", "sab", "sábado", "sabado"]
]
}
}
}

View File

@@ -41,6 +41,8 @@
"priority": "Priorité",
"createdDate": "Date de création",
"newTask": "Nouvelle tâche",
"newTaskDescription": "Notez ce qu'il faut faire. Définissez les propriétés ci-dessous ou plus tard.",
"editTaskDescription": "Mettez à jour les détails, propriétés et pièces jointes de la tâche.",
"noTasksFound": "Aucune tâche trouvée",
"noTasksDescription": "Créez une nouvelle tâche pour commencer ou ajustez vos filtres.",
"taskTitle": "Titre de la tâche",
@@ -309,16 +311,34 @@
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"addEventTitle": "Ajouter un événement",
"addEventDescription": "Ajoutez un ou plusieurs événements au projet, puis enregistrez-les tous ensemble.",
"editEventTitle": "Modifier l'événement",
"eventTitlePlaceholder": "Titre de l'événement",
"pickDate": "Choisir une date",
"pickDateRange": "Choisir une plage de dates",
"pickStart": "Date de début",
"pickEnd": "Date de fin",
"selectProjectOptional": "Sélectionner un projet (optionnel)",
"addAnother": "Ajouter un autre",
"markAsDone": "Marquer comme fait",
"markAsToDo": "Marquer comme à faire",
"editEvent": "Modifier l'événement",
"deleteEvent": "Supprimer l'événement"
"deleteEvent": "Supprimer l'événement",
"endBeforeStart": "La fin doit être après le début",
"dateInvalid": "Date non reconnue",
"batchCreated_one": "1 événement créé",
"batchCreated_other": "{{count}} événements créés",
"batchPartial": "{{ok}} créés, {{failed}} échoués",
"batchFailed": "Impossible de créer les événements",
"staged_one": "1 événement en attente",
"staged_other": "{{count}} événements en attente",
"emptyStagedHint": "Saisissez un titre, choisissez une date, appuyez sur Entrée",
"editRow": "Modifier",
"removeRow": "Retirer",
"projectLocked": "Projet verrouillé après le premier événement",
"confirmCloseStaged": "Abandonner {{count}} événements en attente ?",
"saveAll": "Enregistrer {{count}}",
"update": "Mettre à jour"
},
"projects": {
"noProjectSelected": "Aucun projet sélectionné",
@@ -373,7 +393,30 @@
"untitledNote": "Note sans titre",
"noNotesYet": "Aucune note.",
"noNotesYetDescription": "Cliquez sur \"+ Ajouter\" pour créer votre première note.",
"overview": "Vue d'ensemble"
"overview": "Vue d'ensemble",
"folder": {
"title": "Fichiers",
"linkCta": "Lier un dossier",
"browse": "Parcourir",
"rescan": "Rescanner",
"unlink": "Dissocier",
"filesCount_one": "{{count}} fichier",
"filesCount_other": "{{count}} fichiers",
"lastScanned": "dernier scan {{relative}}",
"scanning": "indexation {{processed}}/{{total}}",
"scanFailed": "Échec du scan",
"empty": {
"title": "Lier un dossier au projet",
"description": "Connectez un dossier local pour que les agents IA puissent lire ses fichiers lorsqu'ils répondent aux questions sur ce projet.",
"cta": "Choisir un dossier…"
},
"errors": {
"tooBig": "Dossier trop grand pour le plan {{tier}} — max {{count}} fichiers",
"monthlyExhausted": "Budget mensuel de tokens épuisé (réinitialisé le {{date}})",
"notFound": "Dossier introuvable : {{path}}"
},
"webOnlyTooltip": "La liaison de dossiers est disponible dans l'application bureau"
}
},
"agents": {
"title": "Agents",
@@ -454,7 +497,11 @@
"updateError": "Impossible de mettre à jour l'événement",
"deleted": "Événement supprimé",
"deleteError": "Impossible de supprimer l'événement",
"historyError": "Impossible d'annuler — l'événement a peut-être changé."
"historyError": "Impossible d'annuler — l'événement a peut-être changé.",
"batchCreated_one": "1 événement créé",
"batchCreated_other": "{{count}} événements créés",
"batchPartial": "{{ok}} créés, {{failed}} échoués",
"batchFailed": "Impossible de créer les événements"
},
"comment": {
"created": "Commentaire ajouté",
@@ -476,5 +523,21 @@
"runStarted": "Exécution de l'agent lancée",
"runError": "Impossible de lancer l'agent"
}
},
"date": {
"keyword": {
"today": ["aujourd'hui", "auj"],
"tomorrow": ["demain"],
"yesterday": ["hier"],
"weekdays": [
["dim", "dimanche"],
["lun", "lundi"],
["mar", "mardi"],
["mer", "mercredi"],
["jeu", "jeudi"],
["ven", "vendredi"],
["sam", "samedi"]
]
}
}
}

View File

@@ -41,6 +41,8 @@
"priority": "Priorità",
"createdDate": "Data creazione",
"newTask": "Nuova attività",
"newTaskDescription": "Annota cosa va fatto. Imposta le proprietà sotto o aggiornale dopo.",
"editTaskDescription": "Aggiorna dettagli, proprietà e allegati dell'attività.",
"noTasksFound": "Nessuna attività trovata",
"noTasksDescription": "Crea una nuova attività per iniziare o modifica i filtri.",
"taskTitle": "Titolo attività",
@@ -309,16 +311,34 @@
"zoomIn": "Ingrandisci",
"zoomOut": "Riduci",
"addEventTitle": "Aggiungi evento timeline",
"addEventDescription": "Aggiungi uno o più eventi al progetto, poi salvali tutti insieme.",
"editEventTitle": "Modifica evento timeline",
"eventTitlePlaceholder": "Titolo evento",
"pickDate": "Seleziona una data",
"pickDateRange": "Seleziona un intervallo di date",
"pickStart": "Data inizio",
"pickEnd": "Data fine",
"selectProjectOptional": "Seleziona progetto (opzionale)",
"addAnother": "Aggiungi un altro",
"markAsDone": "Segna come fatto",
"markAsToDo": "Segna come da fare",
"editEvent": "Modifica evento",
"deleteEvent": "Elimina evento"
"deleteEvent": "Elimina evento",
"endBeforeStart": "La fine deve essere dopo l'inizio",
"dateInvalid": "Data non riconosciuta",
"batchCreated_one": "1 evento creato",
"batchCreated_other": "{{count}} eventi creati",
"batchPartial": "{{ok}} creati, {{failed}} falliti",
"batchFailed": "Impossibile creare gli eventi",
"staged_one": "1 evento in coda",
"staged_other": "{{count}} eventi in coda",
"emptyStagedHint": "Inserisci un titolo, imposta una data, premi Invio",
"editRow": "Modifica",
"removeRow": "Rimuovi",
"projectLocked": "Progetto bloccato dopo il primo evento",
"confirmCloseStaged": "Eliminare {{count}} eventi in coda?",
"saveAll": "Salva {{count}}",
"update": "Aggiorna"
},
"projects": {
"noProjectSelected": "Nessun progetto selezionato",
@@ -373,7 +393,30 @@
"untitledNote": "Nota senza titolo",
"noNotesYet": "Nessuna nota.",
"noNotesYetDescription": "Clicca \"+ Aggiungi\" per creare la tua prima nota.",
"overview": "Panoramica"
"overview": "Panoramica",
"folder": {
"title": "File",
"linkCta": "Collega cartella",
"browse": "Sfoglia",
"rescan": "Riscansiona",
"unlink": "Scollega",
"filesCount_one": "{{count}} file",
"filesCount_other": "{{count}} file",
"lastScanned": "ultima scansione {{relative}}",
"scanning": "indicizzazione {{processed}}/{{total}}",
"scanFailed": "Scansione fallita",
"empty": {
"title": "Collega una cartella al progetto",
"description": "Connetti una cartella locale in modo che gli agenti AI possano leggerne i file quando rispondono alle domande su questo progetto.",
"cta": "Scegli cartella…"
},
"errors": {
"tooBig": "Cartella troppo grande per il piano {{tier}} — max {{count}} file",
"monthlyExhausted": "Budget mensile token esaurito (si rinnova il {{date}})",
"notFound": "Cartella non trovata: {{path}}"
},
"webOnlyTooltip": "Il collegamento cartelle è disponibile nell'app desktop"
}
},
"agents": {
"title": "Agenti",
@@ -454,7 +497,11 @@
"updateError": "Impossibile aggiornare l'evento",
"deleted": "Evento eliminato",
"deleteError": "Impossibile eliminare l'evento",
"historyError": "Impossibile annullare — l'evento potrebbe essere cambiato."
"historyError": "Impossibile annullare — l'evento potrebbe essere cambiato.",
"batchCreated_one": "1 evento creato",
"batchCreated_other": "{{count}} eventi creati",
"batchPartial": "{{ok}} creati, {{failed}} falliti",
"batchFailed": "Impossibile creare gli eventi"
},
"comment": {
"created": "Commento aggiunto",
@@ -476,5 +523,21 @@
"runStarted": "Esecuzione agente avviata",
"runError": "Impossibile avviare l'agente"
}
},
"date": {
"keyword": {
"today": ["oggi"],
"tomorrow": ["domani"],
"yesterday": ["ieri"],
"weekdays": [
["dom", "domenica"],
["lun", "lunedì", "lunedi"],
["mar", "martedì", "martedi"],
["mer", "mercoledì", "mercoledi"],
["gio", "giovedì", "giovedi"],
["ven", "venerdì", "venerdi"],
["sab", "sabato"]
]
}
}
}

View File

@@ -52,6 +52,9 @@ export const ToolCallActionSchema = z.enum([
'list_directory',
'read_file_content',
'get_file_metadata',
'read_project_folder_manifest',
'read_project_folder_file',
'list_projects_with_folder_manifests',
]);
export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
@@ -205,7 +208,8 @@ export const WsStreamEndSchema = z.object({
mutations: z.union([
z.record(z.string(), z.unknown()),
z.array(z.unknown()),
]).optional(),
]).nullish(),
error: z.string().nullish(),
});
export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
@@ -278,6 +282,31 @@ export const WsRunCompleteSchema = z.object({
});
export type WsRunComplete = z.infer<typeof WsRunCompleteSchema>;
export const WsIndexFileResultSchema = z.object({
type: z.literal('index_file_result'),
sessionId: z.string(),
relPath: z.string(),
summary: z.string().nullable().optional(),
tokensUsed: z.number().int().nonnegative().default(0),
error: z.string().optional(),
});
export type WsIndexFileResult = z.infer<typeof WsIndexFileResultSchema>;
export const WsIndexSessionProgressSchema = z.object({
type: z.literal('index_session_progress'),
sessionId: z.string(),
processed: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
});
export type WsIndexSessionProgress = z.infer<typeof WsIndexSessionProgressSchema>;
export const WsIndexSessionDoneSchema = z.object({
type: z.literal('index_session_done'),
sessionId: z.string(),
status: z.enum(['completed', 'cancelled', 'quota_exceeded', 'error']),
});
export type WsIndexSessionDone = z.infer<typeof WsIndexSessionDoneSchema>;
export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsToolCallSchema,
WsPingSchema,
@@ -287,6 +316,9 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
WsFloatingDomainSchema,
WsJourneyReplySchema,
WsRunCompleteSchema,
WsIndexFileResultSchema,
WsIndexSessionProgressSchema,
WsIndexSessionDoneSchema,
]);
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;