refactor(db): separate workout plans from completed sessions

This commit is contained in:
2026-08-12 11:35:16 +02:00
parent f14c2e5827
commit 2bb4225260
16 changed files with 1222 additions and 170 deletions
+35
View File
@@ -0,0 +1,35 @@
CREATE TABLE `workout_sessions` (
`id` text PRIMARY KEY NOT NULL,
`workout_id` text,
`name` text,
`pool_length` real DEFAULT 25 NOT NULL,
`started_at` text NOT NULL,
`finished_at` text,
`total_distance` integer,
`total_duration` real,
`calories` real,
`avg_heart_rate` integer,
`source` text DEFAULT 'manual' NOT NULL,
FOREIGN KEY (`workout_id`) REFERENCES `workouts`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `workout_sessions` (`id`, `workout_id`, `name`, `pool_length`, `started_at`, `finished_at`, `total_distance`, `total_duration`, `calories`, `avg_heart_rate`, `source`)
SELECT `id`, `id`, `name`, `pool_length`, `started_at`, `finished_at`, `total_distance`, `total_duration`, `calories`, `avg_heart_rate`, `source`
FROM `workouts`
WHERE `finished_at` IS NOT NULL;
--> statement-breakpoint
ALTER TABLE `laps` RENAME COLUMN `workout_id` TO `session_id`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `started_at`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `finished_at`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `total_distance`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `total_duration`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `calories`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `avg_heart_rate`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `source`;
+36
View File
@@ -0,0 +1,36 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_laps` (
`id` text PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`set_position` integer NOT NULL,
`rep_number` integer NOT NULL,
`distance` integer NOT NULL,
`duration` real NOT NULL,
`stroke_count` integer,
`heart_rate` integer,
`swolf` real,
FOREIGN KEY (`session_id`) REFERENCES `workout_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_laps`("id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf") SELECT "id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf" FROM `laps`;--> statement-breakpoint
DROP TABLE `laps`;--> statement-breakpoint
ALTER TABLE `__new_laps` RENAME TO `laps`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_workout_sessions` (
`id` text PRIMARY KEY NOT NULL,
`workout_id` text,
`name` text,
`pool_length` real DEFAULT 25 NOT NULL,
`started_at` text NOT NULL,
`finished_at` text,
`total_distance` integer,
`total_duration` real,
`calories` real,
`avg_heart_rate` integer,
`source` text DEFAULT 'manual' NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_workout_sessions`("id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source") SELECT "id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source" FROM `workout_sessions`;--> statement-breakpoint
DROP TABLE `workout_sessions`;--> statement-breakpoint
ALTER TABLE `__new_workout_sessions` RENAME TO `workout_sessions`;--> statement-breakpoint
ALTER TABLE `workouts` ADD `created_at` text NOT NULL;
+397
View File
@@ -0,0 +1,397 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1a621e4a-ea8a-4479-a98c-0f02fc8e14e3",
"prevId": "a9688cc9-9314-4d59-9b68-b7e202f3e3ff",
"tables": {
"laps": {
"name": "laps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"set_position": {
"name": "set_position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_number": {
"name": "rep_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance": {
"name": "distance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke_count": {
"name": "stroke_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"heart_rate": {
"name": "heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"swolf": {
"name": "swolf",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"laps_workout_id_workouts_id_fk": {
"name": "laps_session_id_workout_sessions_id_fk",
"tableFrom": "laps",
"tableTo": "workout_sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sets": {
"name": "sets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke": {
"name": "stroke",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance_per_rep": {
"name": "distance_per_rep",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_count": {
"name": "rep_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rest_type": {
"name": "rest_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'fixed_rest'"
},
"rest_duration": {
"name": "rest_duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"target_time": {
"name": "target_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sets_workout_id_workouts_id_fk": {
"name": "sets_workout_id_workouts_id_fk",
"tableFrom": "sets",
"tableTo": "workouts",
"columnsFrom": [
"workout_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"templates": {
"name": "templates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"json_data": {
"name": "json_data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workouts": {
"name": "workouts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_id": {
"name": "template_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workout_sessions": {
"name": "workout_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_distance": {
"name": "total_distance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_duration": {
"name": "total_duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"calories": {
"name": "calories",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avg_heart_rate": {
"name": "avg_heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'manual'"
}
},
"indexes": {},
"foreignKeys": {
"workout_sessions_workout_id_workouts_id_fk": {
"name": "workout_sessions_workout_id_workouts_id_fk",
"tableFrom": "workout_sessions",
"tableTo": "workouts",
"columnsFrom": [
"workout_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3e3e9dde-1e8c-4a6a-94ac-b5a750829d2b",
"prevId": "1a621e4a-ea8a-4479-a98c-0f02fc8e14e3",
"tables": {
"laps": {
"name": "laps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"set_position": {
"name": "set_position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_number": {
"name": "rep_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance": {
"name": "distance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke_count": {
"name": "stroke_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"heart_rate": {
"name": "heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"swolf": {
"name": "swolf",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"laps_session_id_workout_sessions_id_fk": {
"name": "laps_session_id_workout_sessions_id_fk",
"tableFrom": "laps",
"tableTo": "workout_sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sets": {
"name": "sets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke": {
"name": "stroke",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance_per_rep": {
"name": "distance_per_rep",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_count": {
"name": "rep_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rest_type": {
"name": "rest_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'fixed_rest'"
},
"rest_duration": {
"name": "rest_duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"target_time": {
"name": "target_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sets_workout_id_workouts_id_fk": {
"name": "sets_workout_id_workouts_id_fk",
"tableFrom": "sets",
"tableTo": "workouts",
"columnsFrom": [
"workout_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"templates": {
"name": "templates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"json_data": {
"name": "json_data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workout_sessions": {
"name": "workout_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_distance": {
"name": "total_distance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_duration": {
"name": "total_duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"calories": {
"name": "calories",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avg_heart_rate": {
"name": "avg_heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'manual'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workouts": {
"name": "workouts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_id": {
"name": "template_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+14
View File
@@ -8,6 +8,20 @@
"when": 1785854381700, "when": 1785854381700,
"tag": "0000_mushy_groot", "tag": "0000_mushy_groot",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1785940781700,
"tag": "0001_workout_sessions",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1786525398056,
"tag": "0002_gifted_annihilus",
"breakpoints": true
} }
] ]
} }
+81 -2
View File
@@ -54,9 +54,88 @@ CREATE TABLE \`workouts\` (
); );
`; `;
const m0001 = `
CREATE TABLE \`workout_sessions\` (
\`id\` text PRIMARY KEY NOT NULL,
\`workout_id\` text,
\`name\` text,
\`pool_length\` real DEFAULT 25 NOT NULL,
\`started_at\` text NOT NULL,
\`finished_at\` text,
\`total_distance\` integer,
\`total_duration\` real,
\`calories\` real,
\`avg_heart_rate\` integer,
\`source\` text DEFAULT 'manual' NOT NULL,
FOREIGN KEY (\`workout_id\`) REFERENCES \`workouts\`(\`id\`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO \`workout_sessions\` (\`id\`, \`workout_id\`, \`name\`, \`pool_length\`, \`started_at\`, \`finished_at\`, \`total_distance\`, \`total_duration\`, \`calories\`, \`avg_heart_rate\`, \`source\`)
SELECT \`id\`, \`id\`, \`name\`, \`pool_length\`, \`started_at\`, \`finished_at\`, \`total_distance\`, \`total_duration\`, \`calories\`, \`avg_heart_rate\`, \`source\`
FROM \`workouts\`
WHERE \`finished_at\` IS NOT NULL;
--> statement-breakpoint
ALTER TABLE \`laps\` RENAME COLUMN \`workout_id\` TO \`session_id\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`started_at\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`finished_at\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`total_distance\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`total_duration\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`calories\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`avg_heart_rate\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`source\`;
`;
const m0002 = `
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE \`__new_laps\` (
\`id\` text PRIMARY KEY NOT NULL,
\`session_id\` text NOT NULL,
\`set_position\` integer NOT NULL,
\`rep_number\` integer NOT NULL,
\`distance\` integer NOT NULL,
\`duration\` real NOT NULL,
\`stroke_count\` integer,
\`heart_rate\` integer,
\`swolf\` real,
FOREIGN KEY (\`session_id\`) REFERENCES \`workout_sessions\`(\`id\`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO \`__new_laps\`("id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf") SELECT "id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf" FROM \`laps\`;--> statement-breakpoint
DROP TABLE \`laps\`;--> statement-breakpoint
ALTER TABLE \`__new_laps\` RENAME TO \`laps\`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE \`__new_workout_sessions\` (
\`id\` text PRIMARY KEY NOT NULL,
\`workout_id\` text,
\`name\` text,
\`pool_length\` real DEFAULT 25 NOT NULL,
\`started_at\` text NOT NULL,
\`finished_at\` text,
\`total_distance\` integer,
\`total_duration\` real,
\`calories\` real,
\`avg_heart_rate\` integer,
\`source\` text DEFAULT 'manual' NOT NULL
);
--> statement-breakpoint
INSERT INTO \`__new_workout_sessions\`("id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source") SELECT "id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source" FROM \`workout_sessions\`;--> statement-breakpoint
DROP TABLE \`workout_sessions\`;--> statement-breakpoint
ALTER TABLE \`__new_workout_sessions\` RENAME TO \`workout_sessions\`;--> statement-breakpoint
ALTER TABLE \`workouts\` ADD \`created_at\` text NOT NULL;
`;
export default { export default {
journal, journal,
migrations: { migrations: {
m0000 m0000,
m0001,
m0002
} }
} }
+147 -76
View File
@@ -1,8 +1,9 @@
import { db } from "./client"; import { db } from "./client";
import { generateUUID } from "../utils/uuid"; import { generateUUID } from "../utils/uuid";
import { workouts, sets, laps, templates } from "./schema"; import { workouts, workoutSessions, sets, laps, templates } from "./schema";
import { eq, desc, sql } from "drizzle-orm"; import { eq, desc, sql } from "drizzle-orm";
import type { WorkoutSet, LapData } from "@types/workout"; import type { WorkoutSet, LapData } from "@types/workout";
import WatchConnectivity from "../modules/watch-connectivity";
// ─── Templates ─────────────────────────────────────────────── // ─── Templates ───────────────────────────────────────────────
@@ -30,7 +31,7 @@ export async function toggleTemplateFavorite(id: string, isFavorite: boolean) {
await db.update(templates).set({ isFavorite }).where(eq(templates.id, id)); await db.update(templates).set({ isFavorite }).where(eq(templates.id, id));
} }
// ─── Workouts ──────────────────────────────────────────────── // ─── Workout Plans ───────────────────────────────────────────
export async function saveWorkout( export async function saveWorkout(
name: string | null, name: string | null,
@@ -39,16 +40,13 @@ export async function saveWorkout(
templateId?: string templateId?: string
) { ) {
const workoutId = generateUUID(); const workoutId = generateUUID();
const totalDistance = setsData.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
await db.insert(workouts).values({ await db.insert(workouts).values({
id: workoutId, id: workoutId,
name, name,
templateId: templateId ?? null, templateId: templateId ?? null,
poolLength, poolLength,
startedAt: new Date().toISOString(), createdAt: new Date().toISOString(),
totalDistance,
source: "manual",
}); });
for (let i = 0; i < setsData.length; i++) { for (let i = 0; i < setsData.length; i++) {
@@ -69,74 +67,50 @@ export async function saveWorkout(
return workoutId; return workoutId;
} }
export async function finishWorkout(
workoutId: string,
duration: number,
calories: number,
avgHeartRate: number
) {
await db
.update(workouts)
.set({
finishedAt: new Date().toISOString(),
totalDuration: duration,
calories,
avgHeartRate,
})
.where(eq(workouts.id, workoutId));
}
export async function getWorkoutWithSets(workoutId: string) { export async function getWorkoutWithSets(workoutId: string) {
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get(); const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
if (!workout) return null; if (!workout) return null;
const workoutSets = await db.select().from(sets).where(eq(sets.workoutId, workoutId)); const workoutSets = await db.select().from(sets).where(eq(sets.workoutId, workoutId));
const workoutLaps = await db.select().from(laps).where(eq(laps.workoutId, workoutId)); return { ...workout, sets: workoutSets };
return { ...workout, sets: workoutSets, laps: workoutLaps };
} }
export async function getAllWorkouts() { export async function getAllWorkouts() {
return db.select().from(workouts).orderBy(desc(workouts.startedAt)); return db.select().from(workouts).orderBy(desc(workouts.createdAt));
} }
export async function deleteWorkout(id: string) { // ─── Workout Sessions ────────────────────────────────────────
await db.delete(laps).where(eq(laps.workoutId, id));
await db.delete(sets).where(eq(sets.workoutId, id));
await db.delete(workouts).where(eq(workouts.id, id));
}
// ─── Laps ──────────────────────────────────────────────────── export async function finishWorkout(
workoutId: string,
duration: number,
calories: number,
avgHeartRate: number,
totalDistance: number
) {
const sessionId = generateUUID();
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
export async function saveLap(workoutId: string, lap: LapData) { await db.insert(workoutSessions).values({
const swolf = id: sessionId,
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({
id: generateUUID(),
workoutId, workoutId,
setPosition: lap.setPosition, name: workout?.name ?? null,
repNumber: lap.repNumber, poolLength: workout?.poolLength ?? 25,
distance: lap.distance, startedAt: new Date().toISOString(),
duration: lap.duration, finishedAt: new Date().toISOString(),
strokeCount: lap.strokeCount ?? null, totalDistance,
heartRate: lap.heartRate ?? null, totalDuration: duration,
swolf, calories,
avgHeartRate,
source: "phone",
}); });
}
export async function saveLaps(workoutId: string, lapsData: LapData[]) { return sessionId;
for (const lap of lapsData) {
await saveLap(workoutId, lap);
}
}
export async function getWorkoutLaps(workoutId: string) {
return db.select().from(laps).where(eq(laps.workoutId, workoutId));
} }
export async function saveWatchCompletedWorkout(data: { export async function saveWatchCompletedWorkout(data: {
workoutId: string; workoutId: string;
sessionId?: string;
duration: number; duration: number;
totalDistance: number; totalDistance: number;
calories: number; calories: number;
@@ -154,36 +128,48 @@ export async function saveWatchCompletedWorkout(data: {
? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length) ? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length)
: 0; : 0;
const existing = await db // Haal plan op als het bestaat
.select({ id: workouts.id }) const plan = await db
.select()
.from(workouts) .from(workouts)
.where(eq(workouts.id, data.workoutId)) .where(eq(workouts.id, data.workoutId))
.get();
// Gebruik sessionId van de watch, of genereer een nieuwe
const sessionId = data.sessionId ?? generateUUID();
// Check of er al een sessie bestaat voor dit sessionId
const existingSession = await db
.select({ id: workoutSessions.id })
.from(workoutSessions)
.where(eq(workoutSessions.id, sessionId))
.limit(1); .limit(1);
if (existing.length === 0) { if (existingSession.length === 0) {
await db.insert(workouts).values({ await db.insert(workoutSessions).values({
id: data.workoutId, id: sessionId,
name: "Watch Workout", workoutId: data.workoutId,
poolLength: 25, name: plan?.name ?? "Watch Workout",
poolLength: plan?.poolLength ?? 25,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
totalDuration: data.duration,
totalDistance: data.totalDistance || 0, totalDistance: data.totalDistance || 0,
totalDuration: data.duration,
calories: data.calories || 0, calories: data.calories || 0,
avgHeartRate, avgHeartRate,
source: "watch", source: "watch",
}); });
} else { } else {
await db await db
.update(workouts) .update(workoutSessions)
.set({ .set({
finishedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
totalDuration: data.duration, totalDuration: data.duration,
totalDistance: data.totalDistance || 0, totalDistance: data.totalDistance || 0,
calories: data.calories || 0, calories: data.calories || 0,
avgHeartRate, avgHeartRate,
source: "watch",
}) })
.where(eq(workouts.id, data.workoutId)); .where(eq(workoutSessions.id, sessionId));
} }
for (const lap of data.laps) { for (const lap of data.laps) {
@@ -192,7 +178,7 @@ export async function saveWatchCompletedWorkout(data: {
await db.insert(laps).values({ await db.insert(laps).values({
id: generateUUID(), id: generateUUID(),
workoutId: data.workoutId, sessionId,
setPosition: lap.setPosition, setPosition: lap.setPosition,
repNumber: lap.repNumber, repNumber: lap.repNumber,
distance: lap.distance, distance: lap.distance,
@@ -204,6 +190,91 @@ export async function saveWatchCompletedWorkout(data: {
} }
} }
export async function getAllSessions() {
return db
.select()
.from(workoutSessions)
.orderBy(desc(workoutSessions.startedAt));
}
export async function getSessionWithDetails(sessionId: string) {
const session = await db
.select()
.from(workoutSessions)
.where(eq(workoutSessions.id, sessionId))
.get();
if (!session) return null;
const sessionLaps = await db.select().from(laps).where(eq(laps.sessionId, sessionId));
let sessionSets: any[] = [];
if (session.workoutId) {
sessionSets = await db.select().from(sets).where(eq(sets.workoutId, session.workoutId));
}
return { ...session, laps: sessionLaps, sets: sessionSets };
}
// ─── Laps ────────────────────────────────────────────────────
export async function saveLap(sessionId: string, lap: LapData) {
const swolf =
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({
id: generateUUID(),
sessionId,
setPosition: lap.setPosition,
repNumber: lap.repNumber,
distance: lap.distance,
duration: lap.duration,
strokeCount: lap.strokeCount ?? null,
heartRate: lap.heartRate ?? null,
swolf,
});
}
export async function saveLaps(sessionId: string, lapsData: LapData[]) {
for (const lap of lapsData) {
await saveLap(sessionId, lap);
}
}
export async function getSessionLaps(sessionId: string) {
return db.select().from(laps).where(eq(laps.sessionId, sessionId));
}
// ─── Delete ──────────────────────────────────────────────────
export async function deleteSession(sessionId: string) {
await db.delete(laps).where(eq(laps.sessionId, sessionId));
await db.delete(workoutSessions).where(eq(workoutSessions.id, sessionId));
}
export async function deleteSessionAndSync(sessionId: string) {
await deleteSession(sessionId);
await WatchConnectivity.sendWorkoutDeleted(sessionId);
}
export async function deleteWorkout(id: string) {
// Verwijder alle sessies die aan dit plan gelinkt zijn
const sessions = await db
.select({ id: workoutSessions.id })
.from(workoutSessions)
.where(eq(workoutSessions.workoutId, id));
for (const session of sessions) {
await db.delete(laps).where(eq(laps.sessionId, session.id));
}
await db.delete(workoutSessions).where(eq(workoutSessions.workoutId, id));
await db.delete(sets).where(eq(sets.workoutId, id));
await db.delete(workouts).where(eq(workouts.id, id));
}
export async function deleteWorkoutAndSync(id: string) {
await deleteWorkout(id);
await WatchConnectivity.sendWorkoutDeleted(id);
}
// ─── Stats ─────────────────────────────────────────────────── // ─── Stats ───────────────────────────────────────────────────
export async function getWeeklyStats() { export async function getWeeklyStats() {
@@ -213,12 +284,12 @@ export async function getWeeklyStats() {
return db return db
.select({ .select({
count: sql<number>`count(*)`, count: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`, totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`, totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
avgCalories: sql<number>`coalesce(avg(${workouts.calories}), 0)`, avgCalories: sql<number>`coalesce(avg(${workoutSessions.calories}), 0)`,
}) })
.from(workouts) .from(workoutSessions)
.where(sql`${workouts.startedAt} >= ${weekAgo.toISOString()}`) .where(sql`${workoutSessions.startedAt} >= ${weekAgo.toISOString()}`)
.get(); .get();
} }
@@ -238,10 +309,10 @@ export async function getTotalStats() {
return db return db
.select({ .select({
totalWorkouts: sql<number>`count(*)`, totalWorkouts: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`, totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`, totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
}) })
.from(workouts) .from(workoutSessions)
.where(sql`${workouts.finishedAt} IS NOT NULL`) .where(sql`${workoutSessions.finishedAt} IS NOT NULL`)
.get(); .get();
} }
+12 -4
View File
@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { workouts, sets, laps, templates } from "./schema"; import { templates, workouts, workoutSessions, sets, laps } from "./schema";
export const templatesRelations = relations(templates, ({ many }) => ({ export const templatesRelations = relations(templates, ({ many }) => ({
workouts: many(workouts), workouts: many(workouts),
@@ -11,6 +11,14 @@ export const workoutsRelations = relations(workouts, ({ one, many }) => ({
references: [templates.id], references: [templates.id],
}), }),
sets: many(sets), sets: many(sets),
sessions: many(workoutSessions),
}));
export const workoutSessionsRelations = relations(workoutSessions, ({ one, many }) => ({
workout: one(workouts, {
fields: [workoutSessions.workoutId],
references: [workouts.id],
}),
laps: many(laps), laps: many(laps),
})); }));
@@ -22,8 +30,8 @@ export const setsRelations = relations(sets, ({ one }) => ({
})); }));
export const lapsRelations = relations(laps, ({ one }) => ({ export const lapsRelations = relations(laps, ({ one }) => ({
workout: one(workouts, { session: one(workoutSessions, {
fields: [laps.workoutId], fields: [laps.sessionId],
references: [workouts.id], references: [workoutSessions.id],
}), }),
})); }));
+10 -2
View File
@@ -15,6 +15,14 @@ export const workouts = sqliteTable("workouts", {
name: text("name"), name: text("name"),
templateId: text("template_id"), templateId: text("template_id"),
poolLength: real("pool_length").notNull().default(25), poolLength: real("pool_length").notNull().default(25),
createdAt: text("created_at").notNull(),
});
export const workoutSessions = sqliteTable("workout_sessions", {
id: text("id").primaryKey(),
workoutId: text("workout_id"),
name: text("name"),
poolLength: real("pool_length").notNull().default(25),
startedAt: text("started_at").notNull(), startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"), finishedAt: text("finished_at"),
totalDistance: integer("total_distance"), totalDistance: integer("total_distance"),
@@ -40,9 +48,9 @@ export const sets = sqliteTable("sets", {
export const laps = sqliteTable("laps", { export const laps = sqliteTable("laps", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
workoutId: text("workout_id") sessionId: text("session_id")
.notNull() .notNull()
.references(() => workouts.id), .references(() => workoutSessions.id),
setPosition: integer("set_position").notNull(), setPosition: integer("set_position").notNull(),
repNumber: integer("rep_number").notNull(), repNumber: integer("rep_number").notNull(),
distance: integer("distance").notNull(), distance: integer("distance").notNull(),
@@ -5,7 +5,7 @@ import { useNavigation, useRoute } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RouteProp } from "@react-navigation/native"; import type { RouteProp } from "@react-navigation/native";
import type { RootStackParamList } from "@navigation/types"; import type { RootStackParamList } from "@navigation/types";
import { getWorkoutWithSets, getWorkoutLaps } from "@db/operations"; import { getSessionWithDetails } from "@db/operations";
import { WorkoutSummary } from "../components/WorkoutSummary"; import { WorkoutSummary } from "../components/WorkoutSummary";
type Set = { type Set = {
@@ -38,6 +38,8 @@ export function PostSwimScreen() {
sets: Set[]; sets: Set[];
laps: Lap[]; laps: Lap[];
avgSwolf: number | null; avgSwolf: number | null;
totalLengths: number | null;
avgPace: number | null;
} | null>(null); } | null>(null);
useEffect(() => { useEffect(() => {
@@ -45,29 +47,27 @@ export function PostSwimScreen() {
}, []); }, []);
const loadSummary = async () => { const loadSummary = async () => {
const workout = await getWorkoutWithSets(route.params.workoutId); const session = await getSessionWithDetails(route.params.sessionId);
const laps = await getWorkoutLaps(route.params.workoutId); if (!session) return;
if (!workout) return; const totalDuration = session.totalDuration ?? 0;
const totalDuration = workout.laps?.reduce((acc: number, l: any) => acc + l.duration, 0) ?? 0;
const avgSwolf = const avgSwolf =
laps.length > 0 session.laps.length > 0
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length) ? Math.round(session.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / session.laps.length)
: null; : null;
const totalLengths = const totalLengths =
workout.totalDistance && workout.poolLength session.totalDistance && session.poolLength
? Math.round(workout.totalDistance / workout.poolLength) ? Math.round(session.totalDistance / session.poolLength)
: null; : null;
const avgPace = const avgPace =
workout.totalDistance && totalDuration session.totalDistance && totalDuration
? workout.totalDistance / totalDuration ? session.totalDistance / totalDuration
: null; : null;
const setsWithPace = (workout.sets ?? []).map((set: any) => { const setsWithPace = (session.sets ?? []).map((set: any) => {
const setLaps = laps.filter((l: any) => l.setPosition === set.position); const setLaps = session.laps.filter((l: any) => l.setPosition === set.position);
const setDuration = setLaps.reduce((acc: number, l: any) => acc + l.duration, 0); const setDuration = setLaps.reduce((acc: number, l: any) => acc + l.duration, 0);
const setDistance = setLaps.reduce((acc: number, l: any) => acc + l.distance, 0); const setDistance = setLaps.reduce((acc: number, l: any) => acc + l.distance, 0);
const poolPace = setDistance > 0 ? setDuration / setDistance : null; const poolPace = setDistance > 0 ? setDuration / setDistance : null;
@@ -75,14 +75,14 @@ export function PostSwimScreen() {
}); });
setData({ setData({
name: workout.name, name: session.name,
poolLength: workout.poolLength, poolLength: session.poolLength,
totalDistance: workout.totalDistance ?? 0, totalDistance: session.totalDistance ?? 0,
totalDuration, totalDuration,
calories: workout.calories, calories: session.calories,
avgHeartRate: workout.avgHeartRate, avgHeartRate: session.avgHeartRate,
sets: setsWithPace, sets: setsWithPace,
laps, laps: session.laps,
avgSwolf, avgSwolf,
totalLengths, totalLengths,
avgPace, avgPace,
+21 -21
View File
@@ -5,12 +5,12 @@ import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { HistoryStackParamList } from "@navigation/types"; import type { HistoryStackParamList } from "@navigation/types";
import { getAllWorkouts, deleteWorkout } from "@db/operations"; import { getAllSessions, deleteSessionAndSync } from "@db/operations";
import { formatTime } from "@utils/formatTime"; import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance"; import { formatDistance } from "@utils/formatDistance";
import { SwipeableRow } from "@components/SwipeableRow"; import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = { type SessionRow = {
id: string; id: string;
name: string | null; name: string | null;
totalDistance: number | null; totalDistance: number | null;
@@ -24,23 +24,23 @@ export function HistoryScreen() {
const { t } = useTranslation("history"); const { t } = useTranslation("history");
const { t: tc } = useTranslation("common"); const { t: tc } = useTranslation("common");
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]); const [sessions, setSessions] = useState<SessionRow[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const loadWorkouts = async () => { const loadSessions = async () => {
const data = await getAllWorkouts(); const data = await getAllSessions();
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt)); setSessions(data as SessionRow[]);
}; };
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadWorkouts(); loadSessions();
}, []) }, [])
); );
const onRefresh = async () => { const onRefresh = async () => {
setRefreshing(true); setRefreshing(true);
await loadWorkouts(); await loadSessions();
setRefreshing(false); setRefreshing(false);
}; };
@@ -51,17 +51,17 @@ export function HistoryScreen() {
text: tc("delete"), text: tc("delete"),
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await deleteWorkout(id); await deleteSessionAndSync(id);
await loadWorkouts(); await loadSessions();
}, },
}, },
]); ]);
}; };
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => { const grouped = sessions.reduce<Record<string, SessionRow[]>>((acc, s) => {
const date = new Date(w.startedAt).toLocaleDateString(); const date = new Date(s.startedAt).toLocaleDateString();
if (!acc[date]) acc[date] = []; if (!acc[date]) acc[date] = [];
acc[date].push(w); acc[date].push(s);
return acc; return acc;
}, {}); }, {});
@@ -85,25 +85,25 @@ export function HistoryScreen() {
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1"> <Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1">
{date} {date}
</Text> </Text>
{rows.map((w) => ( {rows.map((s) => (
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)} deleteLabel={tc("delete")}> <SwipeableRow key={s.id} onDelete={() => onDelete(s.id)} deleteLabel={tc("delete")}>
<Pressable <Pressable
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })} onPress={() => navigation.navigate("SessionDetail", { sessionId: s.id })}
className="p-4" className="p-4"
> >
<View className="flex-row justify-between items-center"> <View className="flex-row justify-between items-center">
<View> <View>
<Text className="font-semibold text-slate-900"> <Text className="font-semibold text-slate-900">
{w.name || tc("swimSession")} {s.name || tc("swimSession")}
</Text> </Text>
<Text className="text-slate-500 text-sm"> <Text className="text-slate-500 text-sm">
{formatDistance(w.totalDistance ?? 0)} {formatDistance(s.totalDistance ?? 0)}
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""} {s.totalDuration ? ` · ${formatTime(s.totalDuration)}` : ""}
{w.calories ? ` · ${tc("kcal", { count: Math.round(w.calories) })}` : ""} {s.calories ? ` · ${tc("kcal", { count: Math.round(s.calories) })}` : ""}
</Text> </Text>
</View> </View>
<Text className="text-slate-400 text-xs"> <Text className="text-slate-400 text-xs">
{new Date(w.startedAt).toLocaleTimeString([], { {new Date(s.startedAt).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
})} })}
@@ -3,10 +3,10 @@ import { View, Text, Pressable, Alert } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
import type { HistoryStackProps } from "@navigation/types"; import type { HistoryStackProps } from "@navigation/types";
import { getWorkoutWithSets, deleteWorkout } from "@db/operations"; import { getSessionWithDetails, deleteSessionAndSync } from "@db/operations";
import { WorkoutSummary } from "../../active-workout/components/WorkoutSummary"; import { WorkoutSummary } from "../../active-workout/components/WorkoutSummary";
type WorkoutDetail = { type SessionDetail = {
id: string; id: string;
name: string | null; name: string | null;
poolLength: number; poolLength: number;
@@ -16,6 +16,7 @@ type WorkoutDetail = {
avgHeartRate: number | null; avgHeartRate: number | null;
startedAt: string; startedAt: string;
finishedAt: string | null; finishedAt: string | null;
source: string;
sets: { sets: {
stroke: string; stroke: string;
distancePerRep: number; distancePerRep: number;
@@ -35,15 +36,15 @@ type WorkoutDetail = {
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) { export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
const { t: tc } = useTranslation("common"); const { t: tc } = useTranslation("common");
const navigation = useNavigation(); const navigation = useNavigation();
const [workout, setWorkout] = useState<WorkoutDetail | null>(null); const [session, setSession] = useState<SessionDetail | null>(null);
useEffect(() => { useEffect(() => {
loadWorkout(); loadSession();
}, []); }, []);
const loadWorkout = async () => { const loadSession = async () => {
const data = await getWorkoutWithSets(route.params.sessionId); const data = await getSessionWithDetails(route.params.sessionId);
setWorkout(data as WorkoutDetail | null); setSession(data as SessionDetail | null);
}; };
const handleDelete = () => { const handleDelete = () => {
@@ -53,7 +54,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
text: tc("delete"), text: tc("delete"),
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await deleteWorkout(route.params.sessionId); await deleteSessionAndSync(route.params.sessionId);
navigation.goBack(); navigation.goBack();
}, },
}, },
@@ -61,7 +62,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
}; };
useEffect(() => { useEffect(() => {
if (workout) { if (session) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<Pressable onPress={handleDelete} hitSlop={8}> <Pressable onPress={handleDelete} hitSlop={8}>
@@ -70,9 +71,9 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
), ),
}); });
} }
}, [workout]); }, [session]);
if (!workout) { if (!session) {
return ( return (
<View className="flex-1 bg-white items-center justify-center"> <View className="flex-1 bg-white items-center justify-center">
<Text className="text-slate-400">{tc("loading")}</Text> <Text className="text-slate-400">{tc("loading")}</Text>
@@ -81,24 +82,24 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
} }
const avgSwolf = const avgSwolf =
workout.laps.length > 0 session.laps.length > 0
? Math.round( ? Math.round(
workout.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / workout.laps.length session.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / session.laps.length
) )
: null; : null;
const totalLengths = const totalLengths =
workout.totalDistance && workout.poolLength session.totalDistance && session.poolLength
? Math.round(workout.totalDistance / workout.poolLength) ? Math.round(session.totalDistance / session.poolLength)
: null; : null;
const avgPace = const avgPace =
workout.totalDistance && workout.totalDuration session.totalDistance && session.totalDuration
? workout.totalDistance / workout.totalDuration ? session.totalDistance / session.totalDuration
: null; : null;
const setsWithPace = workout.sets.map((set) => { const setsWithPace = session.sets.map((set) => {
const setLaps = workout.laps.filter((l) => l.setPosition === set.position); const setLaps = session.laps.filter((l) => l.setPosition === set.position);
const setDuration = setLaps.reduce((acc, l) => acc + l.duration, 0); const setDuration = setLaps.reduce((acc, l) => acc + l.duration, 0);
const setDistance = setLaps.reduce((acc, l) => acc + l.distance, 0); const setDistance = setLaps.reduce((acc, l) => acc + l.distance, 0);
const poolPace = setDistance > 0 ? setDuration / setDistance : null; const poolPace = setDistance > 0 ? setDuration / setDistance : null;
@@ -107,17 +108,17 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
return ( return (
<WorkoutSummary <WorkoutSummary
name={workout.name} name={session.name}
date={new Date(workout.startedAt).toLocaleDateString()} date={new Date(session.startedAt).toLocaleDateString()}
totalDistance={workout.totalDistance ?? 0} totalDistance={session.totalDistance ?? 0}
totalDuration={workout.totalDuration ?? 0} totalDuration={session.totalDuration ?? 0}
totalLengths={totalLengths} totalLengths={totalLengths}
calories={workout.calories} calories={session.calories}
avgHeartRate={workout.avgHeartRate} avgHeartRate={session.avgHeartRate}
avgPace={avgPace} avgPace={avgPace}
poolLength={workout.poolLength} poolLength={session.poolLength}
sets={setsWithPace} sets={setsWithPace}
laps={workout.laps} laps={session.laps}
avgSwolf={avgSwolf} avgSwolf={avgSwolf}
/> />
); );
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity"; import WatchConnectivity from "../../../modules/watch-connectivity";
import WearConnectivity from "../../../modules/wear-connectivity"; import WearConnectivity from "../../../modules/wear-connectivity";
import { saveWatchCompletedWorkout } from "@db/operations"; import { saveWatchCompletedWorkout, deleteSession } from "@db/operations";
export function useWatchWorkoutSync() { export function useWatchWorkoutSync() {
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -22,11 +22,14 @@ export function useWatchWorkoutSync() {
if (event.type === "workoutCompleted") { if (event.type === "workoutCompleted") {
await saveWatchCompletedWorkout({ await saveWatchCompletedWorkout({
workoutId: event.workoutId, workoutId: event.workoutId,
sessionId: event.sessionId,
duration: event.duration ?? 0, duration: event.duration ?? 0,
totalDistance: event.totalDistance ?? 0, totalDistance: event.totalDistance ?? 0,
calories: event.calories ?? 0, calories: event.calories ?? 0,
laps: event.laps ?? [], laps: event.laps ?? [],
}); });
} else if (event.type === "workoutDeleted") {
await deleteSession(event.workoutId);
} }
} }
} catch (error) { } catch (error) {
@@ -5,18 +5,16 @@ import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { WorkoutStackParamList } from "@navigation/types"; import type { WorkoutStackParamList } from "@navigation/types";
import { getAllWorkouts, deleteWorkout } from "@db/operations"; import { getAllWorkouts, getWorkoutWithSets, deleteWorkoutAndSync } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance"; import { formatDistance } from "@utils/formatDistance";
import { SwipeableRow } from "@components/SwipeableRow"; import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = { type WorkoutRow = {
id: string; id: string;
name: string | null; name: string | null;
totalDistance: number | null; poolLength: number;
totalDuration: number | null; createdAt: string;
startedAt: string; totalDistance?: number;
finishedAt: string | null;
}; };
export function WorkoutListScreen() { export function WorkoutListScreen() {
@@ -28,7 +26,22 @@ export function WorkoutListScreen() {
const loadWorkouts = async () => { const loadWorkouts = async () => {
const data = await getAllWorkouts(); const data = await getAllWorkouts();
setWorkouts(data as WorkoutRow[]); const rows = data as WorkoutRow[];
// Bereken totalDistance per workout op basis van sets
const withDistance = await Promise.all(
rows.map(async (w) => {
const detail = await getWorkoutWithSets(w.id);
if (!detail) return w;
const totalDistance = (detail.sets ?? []).reduce(
(acc: number, s: any) => acc + s.distancePerRep * s.repCount,
0
);
return { ...w, totalDistance };
})
);
setWorkouts(withDistance);
}; };
useFocusEffect( useFocusEffect(
@@ -50,7 +63,7 @@ export function WorkoutListScreen() {
text: tc("delete"), text: tc("delete"),
style: "destructive", style: "destructive",
onPress: async () => { onPress: async () => {
await deleteWorkout(id); await deleteWorkoutAndSync(id);
await loadWorkouts(); await loadWorkouts();
}, },
}, },
@@ -92,18 +105,13 @@ export function WorkoutListScreen() {
{item.name || tc("untitledWorkout")} {item.name || tc("untitledWorkout")}
</Text> </Text>
<Text className="text-slate-500 text-sm mt-1"> <Text className="text-slate-500 text-sm mt-1">
{formatDistance(item.totalDistance ?? 0)} {item.totalDistance ? formatDistance(item.totalDistance) : ""}
{item.totalDuration ? ` · ${formatTime(item.totalDuration)}` : ""} {item.poolLength ? ` · ${tc("pool", { length: item.poolLength })}` : ""}
</Text> </Text>
<Text className="text-slate-400 text-xs mt-1"> <Text className="text-slate-400 text-xs mt-1">
{new Date(item.startedAt).toLocaleDateString()} {new Date(item.createdAt).toLocaleDateString()}
</Text> </Text>
</View> </View>
{item.finishedAt && (
<View className="bg-green-100 rounded-full px-2 py-0.5">
<Text className="text-green-700 text-xs font-medium">{tc("done")}</Text>
</View>
)}
</View> </View>
</Pressable> </Pressable>
</SwipeableRow> </SwipeableRow>
+1 -1
View File
@@ -4,7 +4,7 @@ export type RootStackParamList = {
Main: undefined; Main: undefined;
WorkoutBuilder: { templateId?: string } | undefined; WorkoutBuilder: { templateId?: string } | undefined;
PreSwim: { workoutId: string }; PreSwim: { workoutId: string };
PostSwim: { workoutId: string }; PostSwim: { sessionId: string };
SessionDetail: { sessionId: string }; SessionDetail: { sessionId: string };
}; };
+2
View File
@@ -526,9 +526,11 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
// MARK: - Sync to Phone // MARK: - Sync to Phone
private func syncCompletedWorkout() { private func syncCompletedWorkout() {
let sessionId = UUID().uuidString
let workoutSummary: [String: Any] = [ let workoutSummary: [String: Any] = [
"type": "workoutCompleted", "type": "workoutCompleted",
"workoutId": selectedWorkout?.id ?? "", "workoutId": selectedWorkout?.id ?? "",
"sessionId": sessionId,
"name": selectedWorkout?.name ?? "Workout", "name": selectedWorkout?.name ?? "Workout",
"poolLength": selectedWorkout?.poolLength ?? 0, "poolLength": selectedWorkout?.poolLength ?? 0,
"laps": completedLaps.map { lap in "laps": completedLaps.map { lap in