Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve time tracking overview #7733

Merged
merged 39 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
6572484
[WIP] [ci skip] add expandable rows to see time tracking details
dieknolle3333 Mar 26, 2024
e2bbd06
prepare api for expanded table
fm3 Mar 28, 2024
594acc7
unwrap list of annotations with times
fm3 Mar 28, 2024
fe45a4a
add annotationCount to time tracking overview
fm3 Mar 28, 2024
f8e74f5
add projectName to summedByAnnotation response
fm3 Mar 28, 2024
fa22c81
WIP [ci skip] render expandable table with tasks and annotations
dieknolle3333 Mar 28, 2024
ae01b8e
style expandable table
dieknolle3333 Apr 2, 2024
903947e
add no. tasks and more stats to overview
dieknolle3333 Apr 2, 2024
b4449b5
WIP [ci skip] add annotation stats
dieknolle3333 Apr 2, 2024
d2be807
style table
dieknolle3333 Apr 3, 2024
f6dd9b0
add button to download timespans
dieknolle3333 Apr 3, 2024
a98f3e6
adjust api types to new responses, omit detail view and include annot…
dieknolle3333 Apr 3, 2024
ab5611d
remove code duplication
dieknolle3333 Apr 3, 2024
6ef1a54
fix sorting in table
dieknolle3333 Apr 3, 2024
438a0c7
merge master
dieknolle3333 Apr 3, 2024
6a1c5e1
Merge branch 'master' into time-tracking-expand-table
dieknolle3333 Apr 8, 2024
71916dd
add additional fields to timespans
fm3 Apr 9, 2024
9ded857
adapt frontend api client to new timespans route
fm3 Apr 9, 2024
a8f722a
snapshots
fm3 Apr 9, 2024
278b7b9
merge master
dieknolle3333 Apr 10, 2024
de613c5
add download for user time spans
dieknolle3333 Apr 11, 2024
faa3e8e
extract csv helper and put task fields into timetracking csv export
dieknolle3333 Apr 15, 2024
7700b94
try to fix type in test
dieknolle3333 Apr 15, 2024
ae19303
enable overview for non-privileged users
fm3 Apr 16, 2024
cf67f8f
fix-frontend
fm3 Apr 16, 2024
96c624e
adjust router.tsx to new view, fix fixedExpandableTable and disable t…
dieknolle3333 Apr 16, 2024
3b9d9e4
lint
dieknolle3333 Apr 16, 2024
e8a224c
improve condition and add changelog
dieknolle3333 Apr 16, 2024
ba26262
merge master
dieknolle3333 Apr 16, 2024
fa00c75
address review
dieknolle3333 Apr 23, 2024
88e1af3
Merge branch 'master' into time-tracking-expand-table
dieknolle3333 Apr 24, 2024
d7c35da
WIP: improve styling of table including annotation stats
dieknolle3333 Apr 24, 2024
ea62b77
merge master
dieknolle3333 Apr 24, 2024
d9e1aaa
remove react-google-charts dependency
dieknolle3333 Apr 25, 2024
3de8846
change name of navbar menu entry
dieknolle3333 Apr 25, 2024
b4c92a5
remove unnecessary casts
philippotto Apr 25, 2024
e640e05
avoid margin bottom in annotation table
philippotto Apr 25, 2024
0f6e46d
merge master
dieknolle3333 Apr 29, 2024
5f06259
Merge branch 'master' into time-tracking-expand-table
fm3 May 6, 2024
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
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
- Toasts are shown until WEBKNOSSOS is running in the active browser tab again. Also, the content of most toasts that show errors or warnings is printed to the browser's console. [#7741](https://github.com/scalableminds/webknossos/pull/7741)
- Improved UI speed when editing the description of an annotation. [#7769](https://github.com/scalableminds/webknossos/pull/7769)
- Updated dataset animations to use the new meshing API. Animitation now support ad-hoc meshes and mappings. [#7692](https://github.com/scalableminds/webknossos/pull/7692)
- In the time tracking view, all annotations and tasks can be shown for each user by expanding the table. The individual time spans spent with a task or annotating an explorative annotation can be accessed via CSV export. The detail view including a chart for the individual spans has been removed. [#7733](https://github.com/scalableminds/webknossos/pull/7733)


### Fixed
Expand Down
43 changes: 31 additions & 12 deletions app/controllers/TimeController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,30 @@ class TimeController @Inject()(userService: UserService,
}
}

def timeSummedByAnnotationForUser(userId: String,
start: Long,
end: Long,
annotationTypes: String,
projectIds: Option[String]): Action[AnyContent] = sil.SecuredAction.async {
implicit request =>
for {
userIdValidated <- ObjectId.fromString(userId)
projectIdsValidated <- ObjectId.fromCommaSeparated(projectIds)
annotationTypesValidated <- AnnotationType.fromCommaSeparated(annotationTypes) ?~> "invalidAnnotationType"
user <- userService.findOneCached(userIdValidated) ?~> "user.notFound" ~> NOT_FOUND
isTeamManagerOrAdmin <- userService.isTeamManagerOrAdminOf(request.identity, user)
_ <- bool2Fox(isTeamManagerOrAdmin || user._id == request.identity._id) ?~> "user.notAuthorised" ~> FORBIDDEN
timesByAnnotation <- timeSpanDAO.summedByAnnotationForUser(user._id,
Instant(start),
Instant(end),
annotationTypesValidated,
projectIdsValidated)
} yield Ok(timesByAnnotation)
}

def timeSpansOfUser(userId: String,
startDate: Long,
endDate: Long,
start: Long,
end: Long,
annotationTypes: String,
projectIds: Option[String]): Action[AnyContent] =
sil.SecuredAction.async { implicit request =>
Expand All @@ -60,13 +81,12 @@ class TimeController @Inject()(userService: UserService,
user <- userService.findOneCached(userIdValidated) ?~> "user.notFound" ~> NOT_FOUND
isTeamManagerOrAdmin <- userService.isTeamManagerOrAdminOf(request.identity, user)
_ <- bool2Fox(isTeamManagerOrAdmin || user._id == request.identity._id) ?~> "user.notAuthorised" ~> FORBIDDEN
userJs <- userService.compactWrites(user)
timeSpansJs <- timeSpanDAO.findAllByUserWithTask(user._id,
Instant(startDate),
Instant(endDate),
Instant(start),
Instant(end),
annotationTypesValidated,
projectIdsValidated)
} yield Ok(Json.obj("user" -> userJs, "timelogs" -> timeSpansJs))
} yield Ok(timeSpansJs)
}

def timeOverview(start: Long,
Expand All @@ -76,7 +96,6 @@ class TimeController @Inject()(userService: UserService,
projectIds: Option[String]): Action[AnyContent] =
sil.SecuredAction.async { implicit request =>
for {
_ <- Fox.assertTrue(userService.isTeamManagerOrAdminOfOrg(request.identity, request.identity._organization)) ?~> "notAllowed" ~> FORBIDDEN
teamIdsValidated <- ObjectId.fromCommaSeparated(teamIds) ?~> "invalidTeamId"
annotationTypesValidated <- AnnotationType.fromCommaSeparated(annotationTypes) ?~> "invalidAnnotationType"
_ <- bool2Fox(annotationTypesValidated.nonEmpty) ?~> "annotationTypesEmpty"
Expand All @@ -86,11 +105,11 @@ class TimeController @Inject()(userService: UserService,
usersByTeams <- if (teamIdsValidated.isEmpty) userDAO.findAll else userDAO.findAllByTeams(teamIdsValidated)
admins <- userDAO.findAdminsByOrg(request.identity._organization)
usersFiltered = (usersByTeams ++ admins).distinct
usersWithTimesJs <- timeSpanDAO.timeSummedSearch(Instant(start),
Instant(end),
usersFiltered.map(_._id),
annotationTypesValidated,
projectIdsValidated)
usersWithTimesJs <- timeSpanDAO.timeOverview(Instant(start),
Instant(end),
usersFiltered.map(_._id),
annotationTypesValidated,
projectIdsValidated)
} yield Ok(Json.toJson(usersWithTimesJs))
}

Expand Down
136 changes: 96 additions & 40 deletions app/models/user/time/TimeSpan.scala
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,46 @@ class TimeSpanDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
parsed <- parseAll(r)
} yield parsed

def summedByAnnotationForUser(userId: ObjectId,
start: Instant,
end: Instant,
annotationTypes: List[AnnotationType],
projectIds: List[ObjectId]): Fox[JsValue] =
if (annotationTypes.isEmpty) Fox.successful(Json.arr())
else {
val projectQuery = projectIdsFilterQuery(projectIds)
for {
tuples <- run(
q"""
SELECT a._id, t._id, p.name, SUM(ts.time), ARRAY_REMOVE(ARRAY_AGG(al.statistics), null) AS annotation_layer_statistics
FROM webknossos.timespans_ ts
JOIN webknossos.annotations_ a on ts._annotation = a._id
JOIN webknossos.annotation_layers as al ON al._annotation = a._id
LEFT JOIN webknossos.tasks_ t on a._task = t._id
LEFT JOIN webknossos.projects_ p on t._project = p._id
WHERE ts._user = $userId
AND ts.time > 0
AND ts.created >= $start
AND ts.created < $end
AND $projectQuery
AND a.typ IN ${SqlToken.tupleFromList(annotationTypes)}
GROUP BY a._id, t._id, p.name
ORDER BY a._id
""".as[(String, Option[String], Option[String], Long, String)]
)
parsed = tuples.map { t =>
Json.obj(
"annotation" -> t._1,
"task" -> t._2,
"projectName" -> t._3,
"timeMillis" -> t._4,
"annotationLayerStats" -> parseArrayLiteral(t._5).map(layerStats =>
Json.parse(layerStats).validate[JsObject].getOrElse(Json.obj()))
)
}
} yield Json.toJson(parsed)
}

def findAllByUserWithTask(userId: ObjectId,
start: Instant,
end: Instant,
Expand All @@ -78,9 +118,14 @@ class TimeSpanDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
else {
val projectQuery = projectIdsFilterQuery(projectIds)
for {
tuples <- run(q"""SELECT ts.time, ts.created, a._id, ts._id, t._id, p.name, tt._id, tt.summary
tuples <- run(
q"""SELECT ts._user, mu.email, o.name, d.name, a._id, t._id, p.name, tt._id, tt.summary, ts._id, ts.created, ts.time
FROM webknossos.timespans_ ts
JOIN webknossos.annotations_ a on ts._annotation = a._id
JOIN webknossos.users_ u on ts._user = u._id
JOIN webknossos.multiUsers_ mu on u._multiUser = mu._id
JOIN webknossos.datasets_ d on a._dataset = d._id
JOIN webknossos.organizations_ o on d._organization = o._id
LEFT JOIN webknossos.tasks_ t on a._task = t._id
LEFT JOIN webknossos.projects_ p on t._project = p._id
LEFT JOIN webknossos.taskTypes_ tt on t._taskType = tt._id
Expand All @@ -90,54 +135,64 @@ class TimeSpanDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
AND ts.created < $end
AND $projectQuery
AND a.typ IN ${SqlToken.tupleFromList(annotationTypes)}
""".as[(Long, Instant, String, String, Option[String], Option[String], Option[String], Option[String])])
} yield formatTimespanTuples(tuples)
""".as[(String,
String,
String,
String,
String,
Option[String],
Option[String],
Option[String],
Option[String],
String,
Instant,
Long)])
} yield Json.toJson(tuples.map(formatTimespanTuple))
}

private def formatTimespanTuples(tuples: Vector[
(Long, Instant, String, String, Option[String], Option[String], Option[String], Option[String])]) = {

def formatTimespanTuple(
tuple: (Long, Instant, String, String, Option[String], Option[String], Option[String], Option[String])) = {
def formatDuration(millis: Long): String = {
// example: P3Y6M4DT12H30M5S = 3 years + 9 month + 4 days + 12 hours + 30 min + 5 sec
// only hours, min and sec are important in this scenario
val h = millis / 3600000
val m = (millis / 60000) % 60
val s = (millis.toDouble / 1000) % 60

s"PT${h}H${m}M${s}S"
}

Json.obj(
"time" -> formatDuration(tuple._1),
"timestamp" -> tuple._2,
"annotation" -> tuple._3,
"_id" -> tuple._4,
"task_id" -> tuple._5,
"project_name" -> tuple._6,
"tasktype_id" -> tuple._7,
"tasktype_summary" -> tuple._8
)
}
Json.toJson(tuples.map(formatTimespanTuple))
}
private def formatTimespanTuple(
tuple: (String,
String,
String,
String,
String,
Option[String],
Option[String],
Option[String],
Option[String],
String,
Instant,
Long)) =
Json.obj(
"userId" -> tuple._1,
"userEmail" -> tuple._2,
"datasetOrganization" -> tuple._3,
"datasetName" -> tuple._4,
"annotationId" -> tuple._5,
"taskId" -> tuple._6,
"projectName" -> tuple._7,
"taskTypeId" -> tuple._8,
"taskTypeSummary" -> tuple._9,
"timeSpanId" -> tuple._10,
"timeSpanCreated" -> tuple._11,
"timeSpanTimeMillis" -> tuple._12
)

private def projectIdsFilterQuery(projectIds: List[ObjectId]): SqlToken =
if (projectIds.isEmpty) q"TRUE" // Query did not filter by project, include all
else q"p._id IN ${SqlToken.tupleFromList(projectIds)}"

def timeSummedSearch(start: Instant,
end: Instant,
users: List[ObjectId],
annotationTypes: List[AnnotationType],
projectIds: List[ObjectId]): Fox[List[JsObject]] =
def timeOverview(start: Instant,
end: Instant,
users: List[ObjectId],
annotationTypes: List[AnnotationType],
projectIds: List[ObjectId]): Fox[List[JsObject]] =
if (users.isEmpty || annotationTypes.isEmpty) Fox.successful(List.empty)
else {
val projectQuery = projectIdsFilterQuery(projectIds)
val query =
q"""
SELECT u._id, u.firstName, u.lastName, mu.email, SUM(ts.time)
SELECT u._id, u.firstName, u.lastName, mu.email, SUM(ts.time), COUNT(a._id)
FROM webknossos.timespans_ ts
JOIN webknossos.annotations_ a ON ts._annotation = a._id
JOIN webknossos.users_ u ON ts._user = u._id
Expand All @@ -153,11 +208,11 @@ class TimeSpanDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
GROUP BY u._id, u.firstName, u.lastName, mu.email
"""
for {
tuples <- run(query.as[(ObjectId, String, String, String, Long)])
tuples <- run(query.as[(ObjectId, String, String, String, Long, Int)])
} yield formatSummedSearchTuples(tuples)
}

private def formatSummedSearchTuples(tuples: Seq[(ObjectId, String, String, String, Long)]): List[JsObject] =
private def formatSummedSearchTuples(tuples: Seq[(ObjectId, String, String, String, Long, Int)]): List[JsObject] =
tuples.map { tuple =>
Json.obj(
"user" -> Json.obj(
Expand All @@ -166,7 +221,8 @@ class TimeSpanDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionContext)
"lastName" -> tuple._3,
"email" -> tuple._4
),
"timeMillis" -> tuple._5
"timeMillis" -> tuple._5,
"annotationCount" -> tuple._6
)
}.toList

Expand Down
3 changes: 2 additions & 1 deletion conf/webknossos.latest.routes
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ GET /termsOfService/acceptanceNeeded

# Time Tracking
# Note, there is also /users/:id/loggedTime
GET /time/user/:userId controllers.TimeController.timeSpansOfUser(userId: String, startDate: Long, endDate: Long, annotationTypes: String, projectIds: Option[String])
GET /time/user/:userId/spans controllers.TimeController.timeSpansOfUser(userId: String, start: Long, end: Long, annotationTypes: String, projectIds: Option[String])
GET /time/user/:userId/summedByAnnotation controllers.TimeController.timeSummedByAnnotationForUser(userId: String, start: Long, end: Long, annotationTypes: String, projectIds: Option[String])
GET /time/overview controllers.TimeController.timeOverview(start: Long, end: Long, annotationTypes: String, teamIds: Option[String], projectIds: Option[String])

# Long-Running Jobs
Expand Down
40 changes: 30 additions & 10 deletions frontend/javascripts/admin/admin_rest_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ import type {
APITaskType,
APITeam,
APITimeInterval,
APITimeTracking,
APITimeTrackingPerAnnotation,
APITimeTrackingSpan,
APITracingStore,
APIUpdateActionBatch,
APIUser,
Expand All @@ -68,6 +69,7 @@ import type {
AdditionalCoordinate,
RenderAnimationOptions,
LayerLink,
APITimeTrackingPerUser,
} from "types/api_flow_types";
import { APIAnnotationTypeEnum } from "types/api_flow_types";
import type { LOG_LEVELS, Vector2, Vector3, Vector6 } from "oxalis/constants";
Expand Down Expand Up @@ -1962,24 +1964,42 @@ export function updateUserConfiguration(
});
}

export async function getTimeTrackingForUser(
export async function getTimeTrackingForUserSummedPerAnnotation(
userId: string,
startDate: dayjs.Dayjs,
endDate: dayjs.Dayjs,
annotationTypes: "Explorational" | "Task" | "Task,Explorational",
projectIds?: string[] | null,
): Promise<Array<APITimeTracking>> {
): Promise<Array<APITimeTrackingPerAnnotation>> {
const params = new URLSearchParams({
startDate: startDate.valueOf().toString(),
endDate: endDate.valueOf().toString(),
start: startDate.valueOf().toString(),
end: endDate.valueOf().toString(),
});
if (annotationTypes != null) params.append("annotationTypes", annotationTypes);
if (projectIds != null && projectIds.length > 0)
params.append("projectIds", projectIds.join(","));
const timeTrackingData = await Request.receiveJSON(`/api/time/user/${userId}?${params}`);
const { timelogs } = timeTrackingData;
assertResponseLimit(timelogs);
return timelogs;
const timeTrackingData = await Request.receiveJSON(
`/api/time/user/${userId}/summedByAnnotation?${params}`,
);
assertResponseLimit(timeTrackingData);
return timeTrackingData;
}

export async function getTimeTrackingForUserSpans(
userId: string,
startDate: number,
endDate: number,
annotationTypes: "Explorational" | "Task" | "Task,Explorational",
projectIds?: string[] | null,
): Promise<Array<APITimeTrackingSpan>> {
const params = new URLSearchParams({
start: startDate.toString(),
end: endDate.toString(),
});
if (annotationTypes != null) params.append("annotationTypes", annotationTypes);
if (projectIds != null && projectIds.length > 0)
params.append("projectIds", projectIds.join(","));
return await Request.receiveJSON(`/api/time/user/${userId}/spans?${params}`);
}

export async function getTimeEntries(
Expand All @@ -1988,7 +2008,7 @@ export async function getTimeEntries(
teamIds: string[],
selectedTypes: AnnotationTypeFilterEnum,
projectIds: string[],
) {
): Promise<Array<APITimeTrackingPerUser>> {
dieknolle3333 marked this conversation as resolved.
Show resolved Hide resolved
const params = new URLSearchParams({
start: startMs.toString(),
end: endMs.toString(),
Expand Down
Loading