feat(shared,api): add pause + sort_order columns and contracts

Adds server-side pause accounting and activity ordering primitives.
- WorkSession contract gains paused_seconds (number) and paused_at (ISO string | null).
- Activity contract gains sort_order (number); new ReorderActivitiesInput zod.
- work_sessions += paused_seconds (int NOT NULL DEFAULT 0) + paused_at (timestamp_ms nullable).
- activities += sort_order (int NOT NULL DEFAULT 0).
- toWorkSession / toActivity map the new fields; generated migration 0003.

The new fields are additive; existing api/worker/admin tests stay green.

Products affected: SoleLog backend (apps/api), shared contracts (packages/shared)
This commit is contained in:
Bas van Rossem
2026-06-17 20:49:56 +02:00
parent eae9a53a26
commit 0d82b6efbc
8 changed files with 671 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
ALTER TABLE `activities` ADD `sort_order` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `work_sessions` ADD `paused_seconds` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `work_sessions` ADD `paused_at` integer;

View File

@@ -0,0 +1,608 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f74073b0-c7a9-4329-9b48-69e927e79fc5",
"prevId": "3d48e08d-2ae7-4987-bb60-e5199726c129",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"activities": {
"name": "activities",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"insole_types": {
"name": "insole_types",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[\"Kurk\",\"Berk\",\"3D\"]'"
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"banned": {
"name": "banned",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ban_expires": {
"name": "ban_expires",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"work_sessions": {
"name": "work_sessions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"activity_id": {
"name": "activity_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"insole_type": {
"name": "insole_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pair_count": {
"name": "pair_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 2
},
"start_time": {
"name": "start_time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_seconds": {
"name": "duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"paused_seconds": {
"name": "paused_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"paused_at": {
"name": "paused_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'app'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"work_sessions_userId_idx": {
"name": "work_sessions_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"work_sessions_startTime_idx": {
"name": "work_sessions_startTime_idx",
"columns": [
"start_time"
],
"isUnique": false
}
},
"foreignKeys": {
"work_sessions_user_id_user_id_fk": {
"name": "work_sessions_user_id_user_id_fk",
"tableFrom": "work_sessions",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"work_sessions_activity_id_activities_id_fk": {
"name": "work_sessions_activity_id_activities_id_fk",
"tableFrom": "work_sessions",
"tableTo": "activities",
"columnsFrom": [
"activity_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1781710299706,
"tag": "0002_solid_prodigy",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1781722003307,
"tag": "0003_sharp_giant_girl",
"breakpoints": true
}
]
}

View File

@@ -124,6 +124,7 @@ export const activities = sqliteTable('activities', {
.$type<string[]>()
.notNull()
.default(['Kurk', 'Berk', '3D']),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
@@ -144,6 +145,8 @@ export const workSessions = sqliteTable(
startTime: integer('start_time', { mode: 'timestamp_ms' }).notNull(),
endTime: integer('end_time', { mode: 'timestamp_ms' }), // null = active
durationSeconds: integer('duration_seconds'),
pausedSeconds: integer('paused_seconds').notNull().default(0),
pausedAt: integer('paused_at', { mode: 'timestamp_ms' }), // null = running
status: text('status').notNull().default('active'), // 'active' | 'completed' | 'discarded'
source: text('source').notNull().default('app'), // 'app' | 'manual'
notes: text('notes'),

View File

@@ -19,6 +19,8 @@ export function toWorkSession(
start_time: new Date(row.startTime).toISOString(),
end_time: row.endTime ? new Date(row.endTime).toISOString() : null,
duration_seconds: row.durationSeconds ?? null,
paused_seconds: row.pausedSeconds ?? 0,
paused_at: row.pausedAt ? new Date(row.pausedAt).toISOString() : null,
status: row.status as WorkSession['status'],
source: row.source as WorkSession['source'],
notes: row.notes ?? null,

View File

@@ -16,6 +16,7 @@ function toActivity(row: ActivityRow): Activity {
name: row.name,
insole_types: row.insoleTypes as Activity['insole_types'],
created_at: new Date(row.createdAt).toISOString(),
sort_order: row.sortOrder ?? 0,
};
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { toWorkSession } from '../src/lib/work-session';
import type { workSessions } from '../src/db/schema';
type WorkSessionRow = typeof workSessions.$inferSelect;
function baseRow(overrides: Partial<WorkSessionRow> = {}): WorkSessionRow {
return {
id: 1,
userId: 'user-1',
activityId: 1,
insoleType: 'Kurk',
pairCount: 2,
startTime: new Date('2026-06-17T08:00:00.000Z'),
endTime: null,
durationSeconds: null,
pausedSeconds: 0,
pausedAt: null,
status: 'active',
source: 'app',
notes: null,
createdAt: new Date('2026-06-17T08:00:00.000Z'),
...overrides,
};
}
describe('toWorkSession paused fields', () => {
it('maps pausedSeconds and a null pausedAt', () => {
const result = toWorkSession(baseRow({ pausedSeconds: 120, pausedAt: null }));
expect(result.paused_seconds).toBe(120);
expect(result.paused_at).toBeNull();
});
it('maps a pausedAt Date to its ISO string', () => {
const pausedAt = new Date('2026-06-17T08:05:00.000Z');
const result = toWorkSession(baseRow({ pausedSeconds: 0, pausedAt }));
expect(result.paused_at).toBe(pausedAt.toISOString());
});
});