aboutsummaryrefslogtreecommitdiff
path: root/src/lib/server/services/routines.test.ts
blob: b92db251da589a6c1955f01fea6300642478be3f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165

import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '$lib/server/db/test-helper';
import { ProjectsService } from './projects';
import { RoutinesService } from './routines';

describe('routines service', () => {
	let db: TestDb['db'];
	let service: RoutinesService;
	let projectId: number;

	beforeEach(async () => {
		const testDb = await createTestDb();
		db = testDb.db;
		service = new RoutinesService(db);
		const project = await new ProjectsService(db).add('Household');
		projectId = project.id;
	});

	it('creates a routine with version 1', async () => {
		const r = await service.add(projectId, 'clean kitchen', '7d');
		expect(r.id).toBe(1);
		expect(r.version).toBe(1);
		expect(r.frequency).toBe('7d');
	});

	it('edit inserts a new snapshot version and keeps history', async () => {
		const r = await service.add(projectId, 'clean kitchen', '7d');
		const updated = await service.edit(r.id, {
			name: 'clean kitchen deeply',
			frequency: '3d',
		});
		expect(updated.version).toBe(2);
		expect(updated.frequency).toBe('3d');

		const versions = await service.loadAllVersions(r.id);
		expect(versions).toHaveLength(2);
		expect(versions[0].frequency).toBe('7d');
		expect(versions[1].frequency).toBe('3d');
	});

	it('new routines start with an empty description', async () => {
		const r = await service.add(projectId, 'clean kitchen', '7d');
		expect(r.description).toBe('');
	});

	it('edit is partial: name/frequency edits keep the description and vice-versa', async () => {
		const r = await service.add(projectId, 'clean kitchen', '7d');
		const described = await service.edit(r.id, {
			description: 'wax on, wax off',
		});
		expect(described.name).toBe('clean kitchen');
		expect(described.frequency).toBe('7d');
		expect(described.description).toBe('wax on, wax off');

		const renamed = await service.edit(r.id, {
			name: 'clean the whole kitchen',
		});
		expect(renamed.version).toBe(3);
		expect(renamed.description).toBe('wax on, wax off');
		expect(renamed.frequency).toBe('7d');

		const refreq = await service.edit(r.id, { frequency: '14d' });
		expect(refreq.version).toBe(4);
		expect(refreq.name).toBe('clean the whole kitchen');
		expect(refreq.description).toBe('wax on, wax off');

		const cleared = await service.edit(r.id, { description: '' });
		expect(cleared.version).toBe(5);
		expect(cleared.description).toBe('');
		expect(cleared.name).toBe('clean the whole kitchen');
	});

	it('archive inserts an archived snapshot without removing history', async () => {
		const r = await service.add(projectId, 'clean kitchen', '7d');
		await service.archive(r.id);
		const versions = await service.loadAllVersions(r.id);
		expect(versions).toHaveLength(2);
		expect(versions[0].archived).toBe(false);
		expect(versions[1].archived).toBe(true);

		const dashboard = await service.loadProjectDashboard(
			projectId,
			Temporal.Now.instant(),
		);
		expect(dashboard.routines).toHaveLength(0);
		expect(dashboard.healthScore).toBe(100);
	});

	it('loadArchived lists archived routines; edits keep the archive date', async () => {
		const a = await service.add(projectId, 'clean kitchen', '7d');
		await service.add(projectId, 'water plants', '3d');
		await service.archive(a.id);
		const stamped = (await service.loadAllVersions(a.id))[1].archivedAt;
		expect(stamped).toBeTruthy();

		const edited = await service.edit(a.id, { name: 'clean kitchen deeply' });
		expect(edited.archived).toBe(true);
		expect(edited.archivedAt).toBe(stamped);

		const archived = await service.loadArchived(projectId);
		expect(archived.map((r) => r.id)).toEqual([a.id]);
		expect(archived[0].name).toBe('clean kitchen deeply');
		expect(archived[0].frequency).toBe('7d');
		expect(archived[0].archivedAt).toBe(stamped);
	});

	it('loadArchived sorts most recently archived first', async () => {
		const a = await service.add(projectId, 'first', '1d');
		const b = await service.add(projectId, 'second', '1d');
		await service.archive(a.id);
		await service.archive(b.id);

		const archived = await service.loadArchived(projectId);
		expect(archived).toHaveLength(2);
		// Descending archive date; equal timestamps keep insertion order.
		expect(
			Temporal.Instant.from(archived[0].archivedAt).epochMilliseconds,
		).toBeGreaterThanOrEqual(
			Temporal.Instant.from(archived[1].archivedAt).epochMilliseconds,
		);
	});

	it('complete records the snapshot version it was made against', async () => {
		const r = await service.add(projectId, 'clean kitchen', '7d');
		await service.edit(r.id, { frequency: '5d' });
		const doneAt = Temporal.Now.instant().toString({
			smallestUnit: 'millisecond',
		});
		const completion = await service.complete(r.id, 'with vinegar', doneAt);
		expect(completion.routineVersion).toBe(2);
		expect(completion.note).toBe('with vinegar');
	});

	it('dashboard computes urgency and health from completions', async () => {
		const r = await service.add(projectId, 'clean kitchen', '7d');
		const created = Temporal.Instant.from(r.createdAt);
		await service.complete(
			r.id,
			null,
			created.toString({ smallestUnit: 'millisecond' }),
		);
		// 22 days after completion with a 7-day routine => two periods overdue
		const now = created.toZonedDateTimeISO('UTC').add({ days: 22 }).toInstant();
		const dashboard = await service.loadProjectDashboard(projectId, now);
		expect(dashboard.routines).toHaveLength(1);
		expect(dashboard.routines[0].status).toBe('veryLate');
		expect(dashboard.healthScore).toBe(0);
		const last = dashboard.healthSeries[dashboard.healthSeries.length - 1];
		expect(last.at).toBe(now.epochMilliseconds);
		expect(last.health).toBe(dashboard.healthScore);
	});

	it('overview aggregates per-project health and a global score', async () => {
		await service.add(projectId, 'clean kitchen', '7d');
		const growth = await new ProjectsService(db).add('Personal growth');
		await service.add(growth.id, 'read 30 minutes', '1d');

		const now = Temporal.Now.instant();
		const overview = await service.loadOverview(now);
		expect(overview.projects).toHaveLength(2);
		expect(overview.globalScore).toBe(
			Math.round(overview.projects.reduce((s, p) => s + p.healthScore, 0) / 2),
		);
	});
});