Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Users can now add tasks directly to an existing archived day from the ArchiveEditDialog — a toggleable "Add Task" button in the task table header opens an inline form (title, description, category, project, start/end time) that appends the task and recalculates the day's total duration on save. Context method `addTaskToArchivedDay` handles the optimistic update and persistence
— `src/components/ArchiveEditDialog.tsx`, `src/contexts/TimeTrackingContext.tsx`
- Scaffolded a Tauri iOS build target alongside the existing Mac/Windows desktop app, so the app can now be run in the iOS Simulator (`pnpm tauri:ios:init`, `pnpm tauri:ios:dev`) and eventually shipped to the App Store. Split `src-tauri/src/main.rs` into a shared `lib.rs` (`#[cfg_attr(mobile, tauri::mobile_entry_point)]`) with a thin binary wrapper, and added a `[lib]` crate target (`crate-type = ["staticlib", "cdylib", "rlib"]`) required for mobile builds. The native menu, self-hosted updater, and updater-triggered restart — none of which apply on iOS — are now compiled out on mobile via `#[cfg(desktop)]`; their Tauri permissions moved to a new desktop-only capabilities file (`capabilities/desktop.json`, scoped via `"platforms"`). Added `src-tauri/tauri.ios.conf.json` to override the Tauri `identifier` to the App Store bundle ID (`com.adamjolicoeur.timetraked`) on iOS only, leaving the existing desktop identifier (and its app-data dir/keychain/updater identity) untouched. Also closed a touch-accessibility gap found during the investigation: the command palette (`Cmd/Ctrl+K`) previously had no on-screen trigger, only a keyboard shortcut and a desktop-only menu item — added a header button so it's reachable on iOS, which has neither. `src/lib/platform.ts` gained an `isIOS` check (touch-point-qualified, since iPadOS spoofs a "Macintosh" user agent) used to fix `isMac` misdetecting iPhone/iPad and to gate the startup silent-update check off on iOS. Corrected `index.html`'s CSP, which allowlisted a stale `capacitor://localhost` left over from an earlier Capacitor-based plan (this project ships via Tauri). CI/App Store Connect signing automation is an explicit follow-up, not included here
— `src-tauri/src/lib.rs` (new), `src-tauri/src/main.rs`, `src-tauri/Cargo.toml`, `src-tauri/tauri.ios.conf.json` (new), `src-tauri/capabilities/default.json`, `src-tauri/capabilities/desktop.json` (new), `src/lib/platform.ts`, `src/lib/tauriElectronApiShim.ts`, `src/App.tsx`, `index.html`, `package.json`
- Manual save now shows a toast confirming the save succeeded, or reporting failure so it isn't silently swallowed. Applies to all three manual-save entry points that share the same handler: the `Cmd/Ctrl+S` keyboard shortcut, the Command Palette's "Save Changes" action, and the Electron menu's Save item. `forceSyncToDatabase` now resolves a `boolean` (`true` on full success, `false` if any save failed) instead of `void`, so callers can react to the outcome
Expand Down
260 changes: 259 additions & 1 deletion src/components/ArchiveEditDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Expand All @@ -56,6 +57,8 @@ import {
Edit,
AlertTriangle,
RotateCcw,
Plus,
X,
} from "lucide-react";
import {
formatDuration,
Expand Down Expand Up @@ -119,15 +122,28 @@ export const ArchiveEditDialog: React.FC<ArchiveEditDialogProps> = ({
categories,
projects,
isDayStarted,
addTaskToArchivedDay,
} = useTimeTracking();
const { toast } = useToast();
const { confirmDelete } = useUndoableDelete<DayRecord>();
const [isSummaryEditing, setIsSummaryEditing] = useState(false);
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [isAddingTask, setIsAddingTask] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showRestoreDialog, setShowRestoreDialog] = useState(false);

const [newTaskData, setNewTaskData] = useState({
title: "",
description: "",
project: "none" as string,
category: "none" as string,
});
const [newTaskTimes, setNewTaskTimes] = useState({
startTime: "",
endTime: "",
});

const dayForm = useForm<DayFormValues>({
resolver: zodResolver(dayFormSchema),
mode: "onBlur",
Expand Down Expand Up @@ -174,6 +190,7 @@ export const ArchiveEditDialog: React.FC<ArchiveEditDialogProps> = ({
setIsSummaryEditing(false);
setExpandedTaskId(null);
setShowDeleteConfirm(false);
handleCancelNewTask();
};

// Initialize form data when dialog opens
Expand Down Expand Up @@ -338,6 +355,53 @@ export const ArchiveEditDialog: React.FC<ArchiveEditDialogProps> = ({
setExpandedTaskId((cur) => (cur === id ? null : id));
}, []);

const handleAddTask = () => {
const selectedProject =
newTaskData.project !== "none"
? projects.find((p) => p.id === newTaskData.project)
: undefined;
const selectedCategory =
newTaskData.category !== "none"
? categories.find((c) => c.id === newTaskData.category)
: undefined;

const baseDate = day.startTime;
const startTime = parseTimeInput(newTaskTimes.startTime, baseDate);
const endTime = newTaskTimes.endTime
? parseTimeInput(newTaskTimes.endTime, baseDate)
: undefined;

const newTask: Task = {
id: crypto.randomUUID(),
title: newTaskData.title.trim(),
description: newTaskData.description.trim() || undefined,
project: selectedProject?.name || undefined,
client: selectedProject?.client || undefined,
category: selectedCategory?.id || undefined,
startTime,
endTime,
duration: endTime
? endTime.getTime() - startTime.getTime()
: undefined,
};

handleCancelNewTask();
setIsSaving(true);
addTaskToArchivedDay(day.id, newTask)
.catch((error) => {
console.error("Failed to add task to archived day:", error);
})
.finally(() => {
setIsSaving(false);
});
};

const handleCancelNewTask = () => {
setNewTaskData({ title: "", description: "", project: "none", category: "none" });
setNewTaskTimes({ startTime: "", endTime: "" });
setIsAddingTask(false);
};

const handleCancel = () => {
resetFormState();
};
Expand Down Expand Up @@ -604,7 +668,30 @@ export const ArchiveEditDialog: React.FC<ArchiveEditDialogProps> = ({
{/* Tasks Detail */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Tasks ({tasks.length})</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Tasks ({tasks.length})</CardTitle>
<Button
onClick={() => {
if (!isAddingTask) {
const baseDate = day.startTime;
const startTimeStr = formatTimeForInput(baseDate);
const defaultEndTime = new Date(baseDate.getTime() + 3600000);
const endTimeStr = formatTimeForInput(defaultEndTime);
setNewTaskTimes({ startTime: startTimeStr, endTime: endTimeStr });
}
setIsAddingTask((prev) => !prev);
}}
variant="outline"
size="sm"
>
{isAddingTask ? (
<X className="w-3 h-3 mr-1" />
) : (
<Plus className="w-3 h-3 mr-1" />
)}
{isAddingTask ? "Cancel" : "Add Task"}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
Expand Down Expand Up @@ -633,6 +720,177 @@ export const ArchiveEditDialog: React.FC<ArchiveEditDialogProps> = ({
projects={projects}
/>
))}
{isAddingTask && (
<TableRow>
<TableCell colSpan={7}>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="new-task-title">
Task Title <span className="text-destructive">*</span>
</Label>
<Input
id="new-task-title"
value={newTaskData.title}
onChange={(e) =>
setNewTaskData((prev) => ({ ...prev, title: e.target.value }))
}
placeholder="Enter task title"
autoFocus
/>
</div>

<div>
<Label htmlFor="new-task-description">
Description
</Label>
<Tabs defaultValue="edit" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="edit">Edit</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="edit">
<Textarea
id="new-task-description"
value={newTaskData.description}
onChange={(e) =>
setNewTaskData((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Enter task description (optional, supports Markdown)"
className="min-h-[80px] resize-none"
/>
</TabsContent>
<TabsContent value="preview">
<div className="w-full min-h-[80px] p-3 border rounded-md bg-background">
{newTaskData.description ? (
<MarkdownDisplay content={newTaskData.description} />
) : (
<p className="text-sm text-muted-foreground">
No description to preview
</p>
)}
</div>
</TabsContent>
</Tabs>
</div>

<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="new-task-category">
Category
</Label>
<ResponsiveSelect
id="new-task-category"
value={newTaskData.category}
onValueChange={(value) =>
setNewTaskData((prev) => ({ ...prev, category: value }))
}
placeholder="Select category"
options={[
{ value: "none", label: "No category" },
...categories.map((c) => ({
value: c.id,
textLabel: c.name,
label: (
<div className="flex items-center space-x-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: c.color }}
/>
<span>{c.name}</span>
</div>
),
})),
]}
/>
</div>

<div>
<Label htmlFor="new-task-project">
Project
</Label>
<ResponsiveSelect
id="new-task-project"
value={newTaskData.project}
onValueChange={(value) =>
setNewTaskData((prev) => ({ ...prev, project: value }))
}
placeholder="Select project"
options={[
{ value: "none", label: "No project" },
...projects.map((p) => ({
value: p.id,
textLabel: `${p.name} (${p.client})`,
label: (
<div className="flex flex-col">
<span>{p.name}</span>
<span className="text-sm text-muted-foreground">
{p.client}
</span>
</div>
),
})),
]}
/>
</div>
</div>

<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label htmlFor="new-task-start-time">
Start Time
</Label>
<TimePicker
id="new-task-start-time"
value={newTaskTimes.startTime}
onValueChange={(value) =>
setNewTaskTimes((prev) => ({ ...prev, startTime: value }))
}
aria-label="Task start time"
/>
</div>

<div>
<Label htmlFor="new-task-end-time">
End Time
</Label>
<TimePicker
id="new-task-end-time"
value={newTaskTimes.endTime}
onValueChange={(value) =>
setNewTaskTimes((prev) => ({ ...prev, endTime: value }))
}
aria-label="Task end time"
/>
</div>
</div>

<div className="flex justify-end space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelNewTask}
>
Cancel
</Button>
<Button
onClick={handleAddTask}
disabled={!newTaskData.title.trim() || isSaving}
variant="default"
size="sm"
>
{isSaving ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : null}
Add Task
</Button>
</div>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
Expand Down
27 changes: 26 additions & 1 deletion src/contexts/TimeTrackingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
parseCSVImport
} from '@/utils/exportUtils';
import { parseTaskChecklist } from '@/utils/checklistUtils';
import { roundToNearest15Minutes } from '@/utils/timeUtil';
import { roundToNearest15Minutes, formatDate } from '@/utils/timeUtil';
import { SCHEMA_VERSION } from '@/services/localStorageService';
import { useElectronBackup } from '@/hooks/useElectronBackup';

Expand Down Expand Up @@ -225,6 +225,7 @@ interface TimeTrackingContextType {
restoreArchivedDay: (dayId: string) => void;
restoreDeletedArchivedDay: (day: DayRecord) => Promise<void>;
addBackdatedDay: (day: DayRecord) => Promise<void>;
addTaskToArchivedDay: (dayId: string, task: Task) => Promise<void>;

// Export functions
exportToCSV: (startDate?: Date, endDate?: Date) => string;
Expand Down Expand Up @@ -1311,6 +1312,28 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
}
}, [archivedDays, dataService]);

const addTaskToArchivedDay = useCallback(async (
dayId: string,
task: Task
) => {
const day = archivedDays.find(d => d.id === dayId);
if (!day) return;

const updatedTasks = [...day.tasks, task];
const updatedDay: Partial<DayRecord> = {
tasks: updatedTasks,
totalDuration: updatedTasks.reduce((total, t) => total + (t.duration || 0), 0),
};

await updateArchivedDay(dayId, updatedDay);

toast({
title: "Task Added",
description: `"${task.title}" added to ${formatDate(day.startTime)}`,
duration: 5000,
});
}, [archivedDays, updateArchivedDay]);

// Category management functions - NO AUTOMATIC SAVING
const addCategory = useCallback((category: Omit<TaskCategory, 'id'>) => {
const newCategory: TaskCategory = {
Expand Down Expand Up @@ -1711,6 +1734,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
restoreArchivedDay,
restoreDeletedArchivedDay,
addBackdatedDay,
addTaskToArchivedDay,
adjustTaskTime,
exportToCSV,
exportToJSON,
Expand Down Expand Up @@ -1780,6 +1804,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
restoreArchivedDay,
restoreDeletedArchivedDay,
addBackdatedDay,
addTaskToArchivedDay,
adjustTaskTime,
exportToCSV,
exportToJSON,
Expand Down
Loading