Compare commits

...

6 Commits

Author SHA1 Message Date
Gregor Vostrak
50f57f6997 add support for activity columns in clockify exports 2026-06-25 12:10:44 +02:00
Gregor Vostrak
ab9f6e6afb adapt clockify importer to support task -> tasks column rename 2026-06-25 00:20:29 +02:00
Gregor Vostrak
2a40bb7edb add support for archived projects in the clockify importer 2026-06-25 00:08:42 +02:00
Gregor Vostrak
365f672cfc clarify import order for clockify and harvest 2026-06-25 00:02:41 +02:00
Gregor Vostrak
25f30fa820 fix destructive color contrast 2026-06-24 17:44:56 +02:00
Constantin Graf
37a8f43b23 Allow Clockify import without Billable column 2026-06-24 13:44:48 +02:00
11 changed files with 184 additions and 18 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use Exception; use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use League\Csv\Exception as CsvException; use League\Csv\Exception as CsvException;
use League\Csv\Reader; use League\Csv\Reader;
@@ -24,6 +25,7 @@ class ClockifyProjectsImporter extends DefaultImporter
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$billableRateKey = $this->getBillableRateKey($header); $billableRateKey = $this->getBillableRateKey($header);
$tasksKey = $this->getTasksKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$clientId = null; $clientId = null;
@@ -44,11 +46,12 @@ class ClockifyProjectsImporter extends DefaultImporter
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]); ]);
} }
if ($record['Task'] !== '') { if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record['Task']); $tasks = explode(', ', $record[$tasksKey]);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
@@ -81,13 +84,33 @@ class ClockifyProjectsImporter extends DefaultImporter
'Status', 'Status',
'Visibility', 'Visibility',
'Billability', 'Billability',
'Task',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
} }
} }
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
if ($this->getTasksKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Tasks');
}
}
/**
* Clockify names the tasks column differently depending on the export
* version: "Task" (older), "Tasks" (newer) or "Activities".
*
* @param array<string> $header
*/
private function getTasksKey(array $header): ?string
{
foreach (['Tasks', 'Task', 'Activities'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
} }
/** /**

View File

@@ -54,6 +54,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape(''); $reader->setEscape('');
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([ $userId = $this->userImportHelper->getKey([
@@ -96,9 +97,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]); ]);
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($taskKey !== null && $record[$taskKey] !== '') {
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record[$taskKey],
'project_id' => $projectId, 'project_id' => $projectId,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
]); ]);
@@ -116,10 +117,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Time entry description is too long'); throw new ImportException('Time entry description is too long');
} }
$timeEntry->description = $record['Description']; $timeEntry->description = $record['Description'];
if (! in_array($record['Billable'], ['Yes', 'No'], true)) { if (isset($record['Billable'])) {
throw new ImportException('Invalid billable value'); if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
} }
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']); $timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
@@ -214,12 +217,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Client', 'Client',
'Description', 'Description',
'Task',
'User', 'User',
'Group', 'Group',
'Email', 'Email',
'Tags', 'Tags',
'Billable',
'Start Date', 'Start Date',
'Start Time', 'Start Time',
'End Date', 'End Date',
@@ -230,6 +231,26 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField); throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
} }
} }
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
} }
#[\Override] #[\Override]

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
return [ return [
'clockify_time_entries' => [ 'clockify_time_entries' => [
'name' => 'Clockify Time Entries', 'name' => 'Clockify Time Entries',
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'. 'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'.
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'. '2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'. '3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '. '4. Now select the date range that you want to export in the right top. '.
@@ -61,7 +62,8 @@ return [
], ],
'harvest_time_entries' => [ 'harvest_time_entries' => [
'name' => 'Harvest Time Entries', 'name' => 'Harvest Time Entries',
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'. 'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'.
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '. '<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.', '<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
], ],

View File

@@ -199,7 +199,7 @@ body {
--muted-foreground: var(--color-text-tertiary); --muted-foreground: var(--color-text-tertiary);
--accent: var(--color-bg-tertiary); --accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary); --accent-foreground: var(--color-text-primary);
--destructive: 0 62.8% 30.6%; --destructive: 0 72% 60%;
--destructive-foreground: var(--color-text-primary); --destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary); --border: var(--color-border-primary);
--input: var(--color-border-tertiary); --input: var(--color-border-tertiary);

View File

@@ -0,0 +1,3 @@
"Project","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Active Project","Big Company","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","","","","","","0.00","0.00","100.01","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
"Archived Project","","Archived","Public","Yes","","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
1 Project Client Status Visibility Billability Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Active Project Big Company Active Public Yes Task 1, Task 2 0.00 100.00 0.00 0.00 100.01 0.00 0.00 0.00 0.00 Constantin Graf
3 Archived Project Archived Public Yes 0.00 0.00 0.00 0.00 0.00 0.00 0.00 Constantin Graf

View File

@@ -0,0 +1,2 @@
"Project","Client","Status","Visibility","Billability","Activities","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Project With Activities","","Active","Public","Yes","Activity A, Activity B","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","","",""
1 Project Client Status Visibility Billability Activities Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Project With Activities Active Public Yes Activity A, Activity B 0.00 0.00 0.00 0.00 0.00 0.00 0.00

View File

@@ -0,0 +1,3 @@
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Task User Group Email Tags Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -0,0 +1,3 @@
"Project","Client","Description","Activity","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Activity User Group Email Tags Billable Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend No 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test Yes 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers; namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Service\Import\Importers\ClockifyProjectsImporter; use App\Service\Import\Importers\ClockifyProjectsImporter;
use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -50,4 +52,68 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
// Assert // Assert
$this->checkTestScenarioProjectsOnlyAfterImport(); $this->checkTestScenarioProjectsOnlyAfterImport();
} }
public function test_import_sets_archived_at_based_on_status_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertNull($activeProject->archived_at);
$this->assertFalse($activeProject->is_archived);
$archivedProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Archived Project')->firstOrFail();
$this->assertNotNull($archivedProject->archived_at);
$this->assertTrue($archivedProject->is_archived);
}
public function test_import_supports_renamed_tasks_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Newer Clockify exports rename the "Task" column to "Tasks".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Task 1', 'Task 2'],
Task::query()->where('project_id', $activeProject->id)->pluck('name')->all(),
);
}
public function test_import_supports_activities_column_alias_for_tasks(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports name the tasks column "Activities".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_3.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project With Activities')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Activity A', 'Activity B'],
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
);
}
} }

View File

@@ -41,6 +41,30 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->clientsCreated); $this->assertSame(1, $report->clientsCreated);
} }
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
$this->checkTimeEntries($testScenario, false, true);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_with_special_characters_description_succeeds(): void public function test_import_of_test_with_special_characters_description_succeeds(): void
{ {
// Arrange // Arrange
@@ -93,6 +117,25 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_supports_activity_column_alias_for_task(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
// Some Clockify exports name the task column "Activity".
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(1, $report->tasksCreated);
}
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
{ {
// Arrange // Arrange

View File

@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
*/ */
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object
{ {
$users = User::all(); $users = User::all();
$this->assertCount(2, $users); $this->assertCount(2, $users);
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('#ef5350', $project1->color); $this->assertSame('#ef5350', $project1->color);
$this->assertSame(null, $project1->billable_rate); $this->assertSame(null, $project1->billable_rate);
// Project for Big Company // Project for Big Company
$this->assertSame(true, $project2->is_billable); $this->assertSame(! $billableDefault, $project2->is_billable);
$this->assertSame(false, $project2->is_public); $this->assertSame(false, $project2->is_public);
$this->assertSame('#ec407a', $project2->color); $this->assertSame('#ec407a', $project2->color);
$this->assertSame(10001, $project2->billable_rate); $this->assertSame(10001, $project2->billable_rate);
// Project (Archived) // Project (Archived)
$this->assertSame(true, $project3->is_billable); $this->assertSame(! $billableDefault, $project3->is_billable);
$this->assertSame(true, $project3->is_public); $this->assertSame(true, $project3->is_public);
$this->assertSame('#6a407f', $project3->color); $this->assertSame('#6a407f', $project3->color);
$this->assertSame(null, $project3->billable_rate); $this->assertSame(null, $project3->billable_rate);
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
/** /**
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario * @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
*/ */
protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void
{ {
$timeEntries = TimeEntry::all(); $timeEntries = TimeEntry::all();
if ($secondRun) { if ($secondRun) {
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('Working hard', $timeEntry2->description); $this->assertSame('Working hard', $timeEntry2->description);
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString()); $this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
$this->assertTrue($timeEntry2->billable); $this->assertSame(! $billableDefault, $timeEntry2->billable);
$this->assertTrue($timeEntry2->is_imported); $this->assertTrue($timeEntry2->is_imported);
$this->assertSame([], $timeEntry2->tags); $this->assertSame([], $timeEntry2->tags);
} }