Skip to content

Commit ad02d6e

Browse files
authored
Dev/frontend update (#312)
Changes Updated frontend dependencies to latest versions Refactored themes to only offer 2 for simplicity: dark and light (and system, which chooses the system default) Removed the stale theme-ui and styled-components, migrating to tailwind, headlessui/react and daisyUI for theming and UI building blocks Restructured frontend components into primitives, ui components and features for better DX Detailed guides on how to use the component system and other DX specific things to make dev onboarding easier Got rid of getInitialProps, moved to getServerSideProps in NextJS Locale is now entirely cookie>user-agent based to allow us to not use locale prefixes. This will make sharing links between people with different locales a more consistent experience. Mobile version is no longer a switch based on user agent strings but completely responsive Replaced icons with lucide-react and simple-icons/react (for brands), removing all non-libre icons Refactored all views, streamlining lists (replacing the user list experiment with a traditional list), adding more information Refactored all forms, form element components for better UX, including filter forms, modal forms, ... Refactored components that we built custom to use headlessui and daisyUI primitives whenever possible, like modals, selects, etc - using daisyUI for styling and headlessui for interactivity Use class variance authority patterns whenever possible Complete new set of empty states based on context Projects are shown with a last activity date i18n string keys are no longer the default values but actual keys based on context plus a default value Nivo charts got more colors and they are now assigned based on hashing the display value instead of the default random assignment. The palette is adjusted for light and dark mode. Add backend config for minimal demo dataset (users only) Removed the no longer needed layout wrapping (getLayout()) Replaced lodash with es-toolkit Notable new features and improvements Tag group administration UI redesigned Working hours definition UI redesigned Time input UI redesigned Ability to choose from existing, favourite or team bookings when creating a new booking (opposed to starting a booking from an existing one via context menu) Stats data can now be exported and received some love as well All exports now available as CSV, ODS or XLSX New dashboard with a focus on personal time management, rather than business intelligence. Dashboard now also calculates monthly time based on planned working hours. Also will notify if booked time is higher than planned. Plausible event tracking actually - finally - works Ability to move start/end times in multiple places: if gaps were detected directly on the notification ui element, the context menu and the booking form itself Specials Online help system, context based, with information about each view and modals Onboarding with checklist and helpful information to get a fresh user started Extended locales: we now offer en, de, es, fr, it (well, machine translated) .. and many, many more small optimisations and improvements to make working with Lasius even easier.
1 parent 19bada3 commit ad02d6e

File tree

913 files changed

+50573
-24566
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

913 files changed

+50573
-24566
lines changed

backend/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ activator-*.sbt
2323
.bloop/
2424
.metals/
2525
metals.sbt
26+
27+
# MCP/Agents
28+
CLAUDE.md
29+
/.serena
30+
/.claude
31+
/.playwright*

backend/app/controllers/ProjectsController.scala

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,17 @@ package controllers
2424
import com.typesafe.config.Config
2525
import core.SystemServices
2626
import models._
27+
import models.BaseFormat.localDateTimeFormat
2728
import org.joda.time.DateTime
2829
import play.api.libs.json.Json
2930
import play.api.mvc.{Action, ControllerComponents}
3031
import play.modules.reactivemongo.ReactiveMongoApi
31-
import repositories.{InvitationRepository, ProjectRepository, UserRepository}
32+
import repositories.{
33+
BookingHistoryRepository,
34+
InvitationRepository,
35+
ProjectRepository,
36+
UserRepository
37+
}
3238

3339
import javax.inject.Inject
3440
import scala.concurrent.{ExecutionContext, Future}
@@ -41,15 +47,17 @@ class ProjectsController @Inject() (
4147
override val reactiveMongoApi: ReactiveMongoApi,
4248
projectRepository: ProjectRepository,
4349
userRepository: UserRepository,
44-
invitationRepository: InvitationRepository)(implicit ec: ExecutionContext)
50+
invitationRepository: InvitationRepository,
51+
bookingHistoryRepository: BookingHistoryRepository)(implicit
52+
ec: ExecutionContext)
4553
extends BaseLasiusController() {
4654
def getProjects(orgId: OrganisationId): Action[Unit] =
4755
HasUserRole(FreeUser, parse.empty, withinTransaction = false) {
4856
implicit dbSession => implicit subject => user => implicit request =>
4957
HasOrganisationRole(user, orgId, OrganisationAdministrator) { userOrg =>
5058
projectRepository
5159
.findByOrganisation(userOrg.organisationReference)
52-
.map(p => Ok(Json.toJson(p)))
60+
.map(projects => Ok(Json.toJson(projects)))
5361
}
5462
}
5563

@@ -132,6 +140,32 @@ class ProjectsController @Inject() (
132140
}
133141
}
134142

143+
def getLastActivityDate(orgId: OrganisationId,
144+
projectId: ProjectId): Action[Unit] =
145+
HasUserRole(FreeUser, parse.empty, withinTransaction = false) {
146+
implicit dbSession => implicit subject => user => implicit request =>
147+
isOrgAdminOrHasProjectRoleInOrganisation(user,
148+
orgId,
149+
projectId,
150+
ProjectMember) { _ =>
151+
logger.debug(s"Getting last activity for project ${projectId.value}")
152+
bookingHistoryRepository
153+
.findLastActivityDateByProjects(Seq(projectId))
154+
.map { dates =>
155+
logger.debug(s"Found dates: $dates")
156+
dates.get(projectId) match {
157+
case Some(date) =>
158+
logger.debug(s"Returning date: $date")
159+
Ok(Json.toJson(date))
160+
case None =>
161+
logger.debug(
162+
s"No activity found for project ${projectId.value}")
163+
NoContent
164+
}
165+
}
166+
}
167+
}
168+
135169
def getUsers(orgId: OrganisationId, projectId: ProjectId): Action[Unit] =
136170
HasUserRole(FreeUser, parse.empty, withinTransaction = true) {
137171
implicit dbSession => implicit subject => user => implicit request =>
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
*
3+
* Lasius - Open source time tracker for teams
4+
* Copyright (c) Tegonal Genossenschaft (https://tegonal.com)
5+
*
6+
* This file is part of Lasius.
7+
*
8+
* Lasius is free software: you can redistribute it and/or modify it under the
9+
* terms of the GNU Affero General Public License as published by the Free
10+
* Software Foundation, either version 3 of the License, or (at your option)
11+
* any later version.
12+
*
13+
* Lasius is distributed in the hope that it will be useful, but WITHOUT ANY
14+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15+
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
16+
* details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with Lasius. If not, see <https://www.gnu.org/licenses/>.
20+
*/
21+
22+
package core.db
23+
24+
import core.{DBSession, DBSupport, SystemServices}
25+
import models.UserId.UserReference
26+
import models._
27+
import org.mindrot.jbcrypt.BCrypt
28+
import play.api.Logging
29+
import play.modules.reactivemongo.ReactiveMongoApi
30+
import repositories._
31+
32+
import javax.inject.Inject
33+
import scala.annotation.unused
34+
import scala.concurrent.{ExecutionContext, Future}
35+
36+
/*
37+
* Initialize database with users only - no prepopulated projects or bookings
38+
*/
39+
@unused
40+
class InitialUsersOnlyDataLoader @Inject() (
41+
override val reactiveMongoApi: ReactiveMongoApi,
42+
oauthUserRepository: OAuthUserRepository,
43+
userRepository: UserRepository,
44+
organisationRepository: OrganisationRepository,
45+
systemServices: SystemServices)(implicit executionContext: ExecutionContext)
46+
extends Logging
47+
with DBSupport
48+
with InitialDataLoader {
49+
50+
// gets overridden b the withinTransaction call
51+
override val supportTransaction = true
52+
53+
private val user1Key: String = sys.env.getOrElse("DEMO_USER1_KEY", "demo1")
54+
private val user1Email: String =
55+
sys.env.getOrElse("DEMO_USER1_EMAIL", "demo1@lasius.ch")
56+
private val user1PasswordHash: String = BCrypt.hashpw(
57+
sys.env.getOrElse("DEMO_USER1_PASSWORD", "demo"),
58+
BCrypt.gensalt())
59+
60+
private val user2Key: String = sys.env.getOrElse("DEMO_USER2_KEY", "demo2")
61+
private val user2Email: String =
62+
sys.env.getOrElse("DEMO_USER2_EMAIL", "demo2@lasius.ch")
63+
private val user2PasswordHash: String = BCrypt.hashpw(
64+
sys.env.getOrElse("DEMO_USER2_PASSWORD", "demo"),
65+
BCrypt.gensalt())
66+
67+
override def initializeData(supportTransaction: Boolean)(implicit
68+
userReference: UserReference): Future[Unit] = {
69+
logger.debug(
70+
"Initialize users only (with private organizations, no projects/bookings)...")
71+
withDBSession(withTransaction = supportTransaction) { implicit dbSession =>
72+
for {
73+
_ <- initializeUser1()(userReference, dbSession)
74+
_ <- initializeUser2()(userReference, dbSession)
75+
} yield ()
76+
}
77+
}
78+
79+
private def initializeUser1()(implicit
80+
userReference: UserReference,
81+
dbSession: DBSession): Future[Unit] = {
82+
for {
83+
// Create OAuth user
84+
oauthUser <- oauthUserRepository.upsert(
85+
OAuthUser(
86+
id = OAuthUserId(),
87+
email = user1Email,
88+
password = user1PasswordHash,
89+
firstName = Some("Demo"),
90+
lastName = Some("User 1"),
91+
active = true
92+
))
93+
// Create private organisation for user
94+
org <- organisationRepository.create(user1Key, `private` = true)(
95+
systemServices.systemSubject,
96+
dbSession)
97+
// Create user with UserInfo similar to OAuth flow
98+
userInfo = UserInfo(key = user1Key,
99+
firstName = Some("Demo"),
100+
lastName = Some("User 1"),
101+
email = user1Email)
102+
user <- userRepository.createInitialUserBasedOnProfile(
103+
userInfo,
104+
org,
105+
OrganisationAdministrator)
106+
} yield ()
107+
}
108+
109+
private def initializeUser2()(implicit
110+
userReference: UserReference,
111+
dbSession: DBSession): Future[Unit] = {
112+
for {
113+
// Create OAuth user
114+
oauthUser <- oauthUserRepository.upsert(
115+
OAuthUser(
116+
id = OAuthUserId(),
117+
email = user2Email,
118+
password = user2PasswordHash,
119+
firstName = Some("Demo"),
120+
lastName = Some("User 2"),
121+
active = true
122+
))
123+
// Create private organisation for user
124+
org <- organisationRepository.create(user2Key, `private` = true)(
125+
systemServices.systemSubject,
126+
dbSession)
127+
// Create user with UserInfo similar to OAuth flow
128+
userInfo = UserInfo(key = user2Key,
129+
firstName = Some("Demo"),
130+
lastName = Some("User 2"),
131+
email = user2Email)
132+
user <- userRepository.createInitialUserBasedOnProfile(
133+
userInfo,
134+
org,
135+
OrganisationAdministrator)
136+
} yield ()
137+
}
138+
}

backend/app/domain/views/CurrentUserTimeBookingsView.scala

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,14 @@ class CurrentUserTimeBookingsView(val clientReceiver: ClientReceiver,
6262
@unused
6363
def autoUpdateInterval: FiniteDuration = 100.millis
6464

65-
override val receive: Receive = ({ case InitializeCurrentTimeBooking(state) =>
66-
this.state = state
67-
context.become(live)
68-
sender() ! JournalReadingViewIsLive
65+
override val receive: Receive = ({
66+
case InitializeCurrentTimeBooking(state) =>
67+
this.state = state
68+
context.become(live)
69+
sender() ! JournalReadingViewIsLive
70+
case GetCurrentTimeBooking(_) =>
71+
// Handle query even before full initialization (e.g., when no bookings exist)
72+
sender() ! currentUserTimeBookings
6973
}: Receive).orElse(defaultReceive)
7074

7175
override def restoreViewFromState(snapshot: UserTimeBooking): Unit = {
@@ -86,6 +90,15 @@ class CurrentUserTimeBookingsView(val clientReceiver: ClientReceiver,
8690
}
8791

8892
override protected val live: Receive = {
93+
case GetCurrentTimeBooking(_) =>
94+
// check if still on same day
95+
val day = LocalDate.now
96+
if (!day.equals(state.currentDay)) {
97+
val durations = getMapForDay(day)
98+
state = updateBooking(state.booking, day, durations)
99+
}
100+
101+
sender() ! currentUserTimeBookings
89102
case e: UserTimeBookingStartedV2 =>
90103
log.debug(
91104
s"CurrentUserTimeBookingsView -> UserTimeBookingStarted($e.booking)")
@@ -202,15 +215,6 @@ class CurrentUserTimeBookingsView(val clientReceiver: ClientReceiver,
202215
notifyClient()
203216
}
204217
sender() ! Ack
205-
case GetCurrentTimeBooking(_) =>
206-
// check if still on same day
207-
val day = LocalDate.now
208-
if (!day.equals(state.currentDay)) {
209-
val durations = getMapForDay(day)
210-
state = updateBooking(state.booking, day, durations)
211-
}
212-
213-
sender() ! currentUserTimeBookings
214218
}
215219

216220
private def notifyClient(): Unit = {

backend/app/domain/views/JournalReadingView.scala

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,17 @@
2121

2222
package domain.views
2323

24+
import domain.AggregateRoot.{InitializeViewLive, RestoreViewFromState}
25+
import domain.UserTimeBookingAggregate.UserTimeBooking
26+
import models.PersistedEvent
2427
import org.apache.pekko.NotUsed
2528
import org.apache.pekko.actor.SupervisorStrategy.Restart
2629
import org.apache.pekko.actor.{Actor, ActorLogging, OneForOneStrategy}
27-
import pekko.contrib.persistence.mongodb.{
28-
MongoReadJournal,
29-
ScalaDslMongoReadJournal
30-
}
30+
import org.apache.pekko.persistence.query.scaladsl.{CurrentEventsByPersistenceIdQuery, ReadJournal}
3131
import org.apache.pekko.persistence.query.{EventEnvelope, PersistenceQuery}
3232
import org.apache.pekko.stream.Materializer
3333
import org.apache.pekko.stream.scaladsl.Source
34-
import domain.AggregateRoot.{InitializeViewLive, RestoreViewFromState}
35-
import domain.UserTimeBookingAggregate.UserTimeBooking
36-
import models.PersistedEvent
34+
import pekko.contrib.persistence.mongodb.MongoReadJournal
3735

3836
import scala.concurrent.duration.DurationInt
3937
import scala.language.postfixOps
@@ -45,9 +43,21 @@ case object JournalReadingViewIsLive
4543
trait JournalReadingView extends Actor with ActorLogging {
4644
val persistenceId: String
4745

48-
private lazy val readJournal: ScalaDslMongoReadJournal =
46+
private lazy val readJournal
47+
: ReadJournal with CurrentEventsByPersistenceIdQuery = {
48+
// Auto-detect journal based on configured persistence plugin
49+
val journalPlugin = context.system.settings.config
50+
.getString("pekko.persistence.journal.plugin")
51+
52+
val journalPluginId = journalPlugin match {
53+
case "inmemory-journal" => "inmemory-read-journal"
54+
case _ => MongoReadJournal.Identifier
55+
}
56+
4957
PersistenceQuery(context.system)
50-
.readJournalFor[ScalaDslMongoReadJournal](MongoReadJournal.Identifier)
58+
.readJournalFor[ReadJournal with CurrentEventsByPersistenceIdQuery](
59+
journalPluginId)
60+
}
5161

5262
override val supervisorStrategy: OneForOneStrategy =
5363
OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1.minute) {

backend/app/domain/views/LatestUserTimeBookingsView.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ class LatestUserTimeBookingsView(clientReceiver: ClientReceiver,
6969
@unused
7070
def autoUpdateInterval: FiniteDuration = 100.millis
7171

72+
override val receive: Receive = ({ case GetLatestTimeBooking(_, maxHistory) =>
73+
// Handle query even before full initialization (e.g., when no bookings exist)
74+
state = state.copy(maxHistory = maxHistory)
75+
notifyClient()
76+
sender() ! Ack
77+
}: Receive).orElse(defaultReceive)
78+
7279
private def getStartTime(booking: BookingStub): DateTime = {
7380
state.startTimeMap.getOrElse(booking, oldDateTime)
7481
}

backend/app/repositories/BookingHistoryRepository.scala

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ trait BookingHistoryRepository
6262
skip: Option[Int])(implicit
6363
dbSession: DBSession): Future[Iterable[BookingV2]]
6464

65+
def findLastActivityDateByProjects(projectIds: Seq[ProjectId])(implicit
66+
dbSession: DBSession): Future[Map[ProjectId, LocalDateTime]]
67+
6568
def updateBooking(newBooking: BookingV2)(implicit
6669
format: Format[BookingV2],
6770
dbSession: DBSession): Future[Boolean]
@@ -102,6 +105,53 @@ class BookingHistoryMongoRepository @Inject() ()(
102105
dbSession: DBSession): Future[Iterable[BookingV2]] =
103106
findByRange(None, None, Some(projectId), from, to, limit, skip)
104107

108+
override def findLastActivityDateByProjects(projectIds: Seq[ProjectId])(
109+
implicit dbSession: DBSession): Future[Map[ProjectId, LocalDateTime]] = {
110+
if (projectIds.isEmpty) {
111+
Future.successful(Map.empty)
112+
} else {
113+
val collection = coll
114+
import collection.AggregationFramework._
115+
import reactivemongo.api.bson._
116+
117+
val pipeline = List(
118+
Match(
119+
BSONDocument(
120+
"projectReference.id" -> BSONDocument(
121+
"$in" -> projectIds.map(_.value.toString)),
122+
"end" -> BSONDocument("$ne" -> BSONNull)
123+
)),
124+
Sort(Descending("start.dateTime")),
125+
Group(BSONString("$projectReference.id"))(
126+
"lastActivityDate" -> First(BSONString("$start.dateTime")),
127+
"projectId" -> First(BSONString("$projectReference.id"))
128+
)
129+
)
130+
131+
logger.debug(
132+
s"findLastActivityDateByProjects for ${projectIds.size} projects")
133+
134+
collection
135+
.aggregateWith[BSONDocument]()(_ => pipeline)
136+
.collect[List]()
137+
.map { results =>
138+
results.flatMap { doc =>
139+
for {
140+
projectIdValue <- doc.getAsOpt[String]("projectId")
141+
lastActivityStr <- doc.getAsOpt[String]("lastActivityDate")
142+
projectId = ProjectId(java.util.UUID.fromString(projectIdValue))
143+
lastActivity = LocalDateTime.parse(lastActivityStr)
144+
} yield projectId -> lastActivity
145+
}.toMap
146+
}
147+
.recover { case ex =>
148+
logger.error(s"Error finding last activity dates: ${ex.getMessage}",
149+
ex)
150+
Map.empty[ProjectId, LocalDateTime]
151+
}
152+
}
153+
}
154+
105155
private def findByRange(userReference: Option[UserReference],
106156
orgId: Option[OrganisationId],
107157
projectId: Option[ProjectId],

0 commit comments

Comments
 (0)