Skip to content

Commit ed0770b

Browse files
authored
Merge pull request #226 from cuappdev/chimdi/workout-logging-testing
Refactor Workout Logging and Streak Tracking
2 parents cd54998 + 55a2360 commit ed0770b

12 files changed

Lines changed: 639 additions & 103 deletions

.dockerignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ build/
1717

1818
.env
1919

20-
Archive
20+
Archive
21+
22+
service-account-key.json
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Make streaks non-null default 0
2+
3+
Revision ID: 30b29f371489
4+
Revises: 6ec7ce03bb6a
5+
Create Date: 2026-02-10 18:12:25.251531
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '30b29f371489'
14+
down_revision = '6ec7ce03bb6a'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
22+
# First complete backfill: set all streaks (active/max) to 0 if they are NULL
23+
op.execute("UPDATE users SET active_streak = 0 WHERE active_streak IS NULL")
24+
op.execute("UPDATE users SET max_streak = 0 WHERE max_streak IS NULL")
25+
26+
op.alter_column('users', 'active_streak',
27+
existing_type=sa.INTEGER(),
28+
nullable=False,
29+
server_default=sa.text('0')
30+
)
31+
32+
op.alter_column('users', 'max_streak',
33+
existing_type=sa.INTEGER(),
34+
nullable=False,
35+
server_default=sa.text('0'),
36+
)
37+
# ### end Alembic commands ###
38+
39+
40+
def downgrade():
41+
# ### commands auto generated by Alembic - please adjust! ###
42+
op.alter_column('users', 'max_streak',
43+
existing_type=sa.INTEGER(),
44+
nullable=True)
45+
op.alter_column('users', 'active_streak',
46+
existing_type=sa.INTEGER(),
47+
nullable=True)
48+
# ### end Alembic commands ###

migrations/versions/31b1fa20772f_popular_times.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
# revision identifiers, used by Alembic.
1414
revision = '31b1fa20772f'
15-
down_revision = None
15+
down_revision = 'eb948c31a342'
1616
branch_labels = None
1717
depends_on = None
1818

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Debug internal default conversion to UTC
2+
3+
Revision ID: 48923aecacb0
4+
Revises:
5+
Create Date: 2026-03-02 06:30:17.794409
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '48923aecacb0'
15+
down_revision = None
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.alter_column(
22+
"workout",
23+
"workout_time",
24+
type_=sa.DateTime(timezone=True),
25+
postgresql_using="workout_time AT TIME ZONE 'UTC'",
26+
existing_nullable=False,
27+
)
28+
29+
op.alter_column(
30+
"user_workout_goal_history",
31+
"effective_at",
32+
type_=sa.DateTime(timezone=True),
33+
postgresql_using="effective_at AT TIME ZONE 'UTC'",
34+
existing_nullable=False,
35+
)
36+
37+
op.alter_column(
38+
"user_workout_goal_history",
39+
"effective_at",
40+
server_default=sa.text("CURRENT_TIMESTAMP"),
41+
existing_type=sa.DateTime(timezone=True),
42+
existing_nullable=False,
43+
)
44+
45+
46+
def downgrade():
47+
op.alter_column(
48+
"user_workout_goal_history",
49+
"effective_at",
50+
server_default=None,
51+
existing_type=sa.DateTime(timezone=True),
52+
existing_nullable=False,
53+
)
54+
55+
op.alter_column(
56+
"user_workout_goal_history",
57+
"effective_at",
58+
type_=sa.DateTime(timezone=False),
59+
postgresql_using="effective_at::timestamp",
60+
existing_nullable=False,
61+
)
62+
63+
op.alter_column(
64+
"workout",
65+
"workout_time",
66+
type_=sa.DateTime(timezone=False),
67+
postgresql_using="workout_time::timestamp",
68+
existing_nullable=False,
69+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Change workout_goal type to integer
2+
3+
Revision ID: 6ec7ce03bb6a
4+
Revises: add_friends_table
5+
Create Date: 2026-02-09 22:56:02.894228
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '6ec7ce03bb6a'
14+
down_revision = 'add_friends_table'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
op.alter_column("users", "workout_goal", type_=sa.Integer, postgresql_using="cardinality(workout_goal)")
21+
22+
# NOTE: Lossy migration — cannot convert integer back to array of specific days of the week
23+
def downgrade():
24+
raise NotImplementedError("Downgrade is possible: cannot convert integer back to array of specific days of the week")
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Include last_streak and last_goal_change to User model
2+
3+
Revision ID: 6fb4a21a1201
4+
Revises: 30b29f371489
5+
Create Date: 2026-02-17 10:06:17.931547
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '6fb4a21a1201'
14+
down_revision = '30b29f371489'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.add_column('users', sa.Column('last_goal_change', sa.DateTime(), nullable=True))
22+
op.add_column('users', sa.Column('last_streak', sa.Integer(), nullable=True))
23+
# ### end Alembic commands ###
24+
25+
26+
def downgrade():
27+
# ### commands auto generated by Alembic - please adjust! ###
28+
op.drop_column('users', 'last_streak')
29+
op.drop_column('users', 'last_goal_change')
30+
# ### end Alembic commands ###
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Create gear table
2+
3+
Revision ID: eb948c31a342
4+
Revises: 6fb4a21a1201
5+
Create Date: 2026-03-02 06:22:00.042780
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
# Import the PriceType enum used in the model so the Enum can be created
12+
from src.models.activity import PriceType
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision = 'eb948c31a342'
17+
down_revision = '6fb4a21a1201'
18+
branch_labels = None
19+
depends_on = None
20+
21+
22+
def upgrade():
23+
op.create_table(
24+
"gear",
25+
sa.Column("id", sa.Integer(), primary_key=True),
26+
sa.Column("activity_id", sa.Integer(), nullable=False),
27+
sa.Column("name", sa.String(), nullable=False),
28+
sa.Column("cost", sa.Float(), nullable=False),
29+
sa.Column("rate", sa.String(), nullable=True),
30+
sa.Column("type", sa.Enum(PriceType), nullable=False),
31+
)
32+
33+
def downgrade():
34+
op.drop_table("gear")

schema.graphql

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ type CreateReport {
8585
report: Report
8686
}
8787

88+
scalar Date
89+
8890
scalar DateTime
8991

9092
enum DayOfWeekEnum {
@@ -187,8 +189,6 @@ type HourlyAverageCapacity {
187189
history: [Float]!
188190
}
189191

190-
scalar JSONString
191-
192192
type LoginUser {
193193
accessToken: String
194194
refreshToken: String
@@ -218,7 +218,7 @@ type Mutation {
218218
createUser(email: String!, encodedImage: String, name: String!, netId: String!): User
219219
editUser(email: String, encodedImage: String, name: String, netId: String!): User
220220
enterGiveaway(giveawayId: Int!, userNetId: String!): GiveawayInstance
221-
setWorkoutGoals(userId: Int!, workoutGoal: [String]!): User
221+
setWorkoutGoals(userId: Int!, workoutGoal: Int!): User
222222
logWorkout(facilityId: Int!, userId: Int!, workoutTime: DateTime!): Workout
223223
loginUser(netId: String!): LoginUser
224224
logoutUser: LogoutUser
@@ -269,8 +269,6 @@ type Query {
269269
getWorkoutsById(id: Int): [Workout]
270270
activities: [Activity]
271271
getAllReports: [Report]
272-
getWorkoutGoals(id: Int!): [String]
273-
getUserStreak(id: Int!): JSONString
274272
getHourlyAverageCapacitiesByFacilityId(facilityId: Int): [HourlyAverageCapacity]
275273
getUserFriends(userId: Int!): [User]
276274
getCapacityReminderById(id: Int!): CapacityReminder
@@ -307,20 +305,34 @@ type User {
307305
email: String
308306
netId: String!
309307
name: String!
310-
activeStreak: Int
311-
maxStreak: Int
312-
workoutGoal: [DayOfWeekGraphQLEnum]
308+
activeStreak: Int!
309+
maxStreak: Int!
310+
workoutGoal: Int
311+
lastGoalChange: DateTime
312+
lastStreak: Int!
313313
encodedImage: String
314314
giveaways: [Giveaway]
315+
goalHistory: [WorkoutGoalHistory]
315316
friendRequestsSent: [Friendship]
316317
friendRequestsReceived: [Friendship]
317318
friendships: [Friendship]
318319
friends: [User]
320+
totalGymDays: Int!
321+
streakStart: Date
319322
}
320323

321324
type Workout {
322325
id: ID!
323326
workoutTime: DateTime!
324327
userId: Int!
325328
facilityId: Int!
329+
gymName: String!
330+
}
331+
332+
type WorkoutGoalHistory {
333+
id: ID!
334+
userId: Int!
335+
workoutGoal: Int!
336+
effectiveAt: DateTime!
337+
user: User
326338
}

src/models/user.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from sqlalchemy import Column, Integer, String, ARRAY, Enum, ForeignKey
1+
from sqlalchemy import Column, Integer, String, ARRAY, Enum, ForeignKey, DateTime
22
from sqlalchemy.orm import relationship
33
from src.database import Base
44
from src.models.enums import DayOfWeekEnum
55
from src.models.friends import Friendship
6+
import sqlalchemy as sa
67

78
class User(Base):
89
"""
@@ -14,10 +15,12 @@ class User(Base):
1415
- `giveaways` (nullable) The list of giveaways a user is entered into.
1516
- `net_id` The user's Net ID.
1617
- `name` The user's name.
17-
- `workout_goal` The days of the week the user has set as their personal goal.
18+
- `workout_goal` The number of days the user has set as their personal goal.
1819
- `active_streak` The number of consecutive weeks the user has met their personal goal.
1920
- `max_streak` The maximum number of consecutive weeks the user has met their personal goal.
2021
- `workout_goal` The max number of weeks the user has met their personal goal.
22+
- `last_goal_change` The date and time the user last changed their personal goal.
23+
- `last_streak` The number of consecutive weeks the user has met their personal goal before the last goal change.
2124
- `encoded_image` The profile picture URL of the user.
2225
"""
2326

@@ -28,11 +31,15 @@ class User(Base):
2831
giveaways = relationship("Giveaway", secondary="giveaway_instance", back_populates="users")
2932
net_id = Column(String, nullable=False)
3033
name = Column(String, nullable=False)
31-
active_streak = Column(Integer, nullable=True)
32-
max_streak = Column(Integer, nullable=True)
33-
workout_goal = Column(ARRAY(Enum(DayOfWeekEnum)), nullable=True)
34+
active_streak = Column(Integer, nullable=False, default=0, server_default=sa.text('0'))
35+
max_streak = Column(Integer, nullable=False, default=0, server_default=sa.text('0'))
36+
workout_goal = Column(Integer, nullable=True)
37+
last_goal_change = Column(DateTime, nullable=True)
38+
last_streak = Column(Integer, nullable=False, default=0, server_default=sa.text('0'))
3439
encoded_image = Column(String, nullable=True)
3540

41+
goal_history = relationship("UserWorkoutGoalHistory", back_populates="user", cascade="all, delete-orphan", order_by="UserWorkoutGoalHistory.effective_at.desc()")
42+
3643
friend_requests_sent = relationship("Friendship",
3744
foreign_keys="Friendship.user_id",
3845
back_populates="user")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from sqlalchemy import Column, Integer, Float, ForeignKey, String, DateTime, text
2+
from sqlalchemy.orm import backref, relationship
3+
from src.database import Base
4+
from datetime import datetime, timezone
5+
6+
class UserWorkoutGoalHistory(Base):
7+
"""
8+
A history of a user's workout goals.
9+
10+
Attributes:
11+
- `id` The ID of the user workout goal history.
12+
- `user_id` The ID of the user who owns the goal history.
13+
- `workout_goal` The workout goal.
14+
- `effective_at` The date and time the goal was set.
15+
"""
16+
17+
__tablename__ = "user_workout_goal_history"
18+
19+
id = Column(Integer, primary_key=True)
20+
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
21+
workout_goal = Column(Integer, nullable=False)
22+
effective_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), server_default=text("CURRENT_TIMESTAMP"))
23+
24+
user = relationship("User", back_populates='goal_history')

0 commit comments

Comments
 (0)