feat(habits): add opt-in partial count carryover with daily reset defaults
This commit is contained in:
4
drizzle/0002_optional_count_carryover.sql
Normal file
4
drizzle/0002_optional_count_carryover.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `habit_days` ADD `count_set` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
UPDATE habit_days SET count_set = 1 WHERE count != 0 OR EXISTS (SELECT 1 FROM progress_events e WHERE e.day_id = habit_days.id AND json_type(e.after, '$.count') IS NOT NULL);
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE habit_revisions SET config = json_set(config, '$.carryPartialProgress', json('false')) WHERE json_extract(config, '$.method') = 'count';
|
||||||
684
drizzle/meta/0002_snapshot.json
Normal file
684
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "79583aaf-5f48-44cd-b105-407d229c3ced",
|
||||||
|
"prevId": "c1f6559a-c6f4-491e-8201-4f622718de35",
|
||||||
|
"tables": {
|
||||||
|
"combined_charts": {
|
||||||
|
"name": "combined_charts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"habit_ids": {
|
||||||
|
"name": "habit_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"combined_charts_user_idx": {
|
||||||
|
"name": "combined_charts_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"combined_charts_user_id_users_id_fk": {
|
||||||
|
"name": "combined_charts_user_id_users_id_fk",
|
||||||
|
"tableFrom": "combined_charts",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_calendar_settings": {
|
||||||
|
"name": "habit_calendar_settings",
|
||||||
|
"columns": {
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_calendar_settings_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_calendar_settings_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_calendar_settings",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_days": {
|
||||||
|
"name": "habit_days",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revision_id": {
|
||||||
|
"name": "revision_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ends_at": {
|
||||||
|
"name": "ends_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"count_set": {
|
||||||
|
"name": "count_set",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_days_lookup_idx": {
|
||||||
|
"name": "habit_days_lookup_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"date",
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_days_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_days_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"habit_days_revision_id_habit_revisions_id_fk": {
|
||||||
|
"name": "habit_days_revision_id_habit_revisions_id_fk",
|
||||||
|
"tableFrom": "habit_days",
|
||||||
|
"tableTo": "habit_revisions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"revision_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habit_revisions": {
|
||||||
|
"name": "habit_revisions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"habit_id": {
|
||||||
|
"name": "habit_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"effective_date": {
|
||||||
|
"name": "effective_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habit_revisions_date_idx": {
|
||||||
|
"name": "habit_revisions_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"habit_id",
|
||||||
|
"effective_date",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habit_revisions_habit_id_habits_id_fk": {
|
||||||
|
"name": "habit_revisions_habit_id_habits_id_fk",
|
||||||
|
"tableFrom": "habit_revisions",
|
||||||
|
"tableTo": "habits",
|
||||||
|
"columnsFrom": [
|
||||||
|
"habit_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"habits": {
|
||||||
|
"name": "habits",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_date": {
|
||||||
|
"name": "created_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"materialized_through": {
|
||||||
|
"name": "materialized_through",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"habits_user_idx": {
|
||||||
|
"name": "habits_user_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"habits_user_id_users_id_fk": {
|
||||||
|
"name": "habits_user_id_users_id_fk",
|
||||||
|
"tableFrom": "habits",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"progress_events": {
|
||||||
|
"name": "progress_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"occurrence_id": {
|
||||||
|
"name": "occurrence_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"before": {
|
||||||
|
"name": "before",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"name": "after",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"progress_events_day_idx": {
|
||||||
|
"name": "progress_events_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"progress_events_day_id_habit_days_id_fk": {
|
||||||
|
"name": "progress_events_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "progress_events",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"sessions_expires_at_idx": {
|
||||||
|
"name": "sessions_expires_at_idx",
|
||||||
|
"columns": [
|
||||||
|
"expires_at"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"task_occurrences": {
|
||||||
|
"name": "task_occurrences",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"day_id": {
|
||||||
|
"name": "day_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"name": "task_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"name": "done",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"expired_at": {
|
||||||
|
"name": "expired_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"task_occurrences_day_idx": {
|
||||||
|
"name": "task_occurrences_day_idx",
|
||||||
|
"columns": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_occurrences_day_id_habit_days_id_fk": {
|
||||||
|
"name": "task_occurrences_day_id_habit_days_id_fk",
|
||||||
|
"tableFrom": "task_occurrences",
|
||||||
|
"tableTo": "habit_days",
|
||||||
|
"columnsFrom": [
|
||||||
|
"day_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"discord_id": {
|
||||||
|
"name": "discord_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"global_name": {
|
||||||
|
"name": "global_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_hash": {
|
||||||
|
"name": "avatar_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"name": "timezone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'UTC'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_discord_id_unique": {
|
||||||
|
"name": "users_discord_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"discord_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1788505163294,
|
"when": 1788505163294,
|
||||||
"tag": "0001_habit_history",
|
"tag": "0001_habit_history",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788505800600,
|
||||||
|
"tag": "0002_optional_count_carryover",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ export const habitRevisions = sqliteTable('habit_revisions', {
|
|||||||
export const habitDays = sqliteTable('habit_days', {
|
export const habitDays = sqliteTable('habit_days', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }), habitId: text('habit_id').notNull().references(() => habits.id),
|
id: integer('id').primaryKey({ autoIncrement: true }), habitId: text('habit_id').notNull().references(() => habits.id),
|
||||||
revisionId: integer('revision_id').notNull().references(() => habitRevisions.id), date: text('date').notNull(),
|
revisionId: integer('revision_id').notNull().references(() => habitRevisions.id), date: text('date').notNull(),
|
||||||
timezone: text('timezone').notNull(), endsAt: integer('ends_at').notNull(), count: integer('count').notNull().default(0), done: integer('done', { mode: 'boolean' }).notNull().default(false),
|
timezone: text('timezone').notNull(), endsAt: integer('ends_at').notNull(), countSet: integer('count_set', { mode: 'boolean' }).notNull().default(false), count: integer('count').notNull().default(0), done: integer('done', { mode: 'boolean' }).notNull().default(false),
|
||||||
}, t => [index('habit_days_lookup_idx').on(t.habitId, t.date, t.revisionId)]);
|
}, t => [index('habit_days_lookup_idx').on(t.habitId, t.date, t.revisionId)]);
|
||||||
export const taskOccurrences = sqliteTable('task_occurrences', {
|
export const taskOccurrences = sqliteTable('task_occurrences', {
|
||||||
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
|
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ test('combined CRUD, membership edits recompute history, calendar data and setti
|
|||||||
const settings = await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#123456', shadeCount: 6 });
|
const settings = await f.json(`/habits/${h.id}/calendar-settings`, 'PUT', { mainColor: '#123456', shadeCount: 6 });
|
||||||
expect((await f.json(`/habits/${h.id}/calendar-settings`))).toEqual(settings);
|
expect((await f.json(`/habits/${h.id}/calendar-settings`))).toEqual(settings);
|
||||||
const individual = await f.json(`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-04`);
|
const individual = await f.json(`/habits/${h.id}/calendar?from=2026-09-04&to=2026-09-04`);
|
||||||
expect(individual.data).toEqual([{ day: '2026-09-04', value: 8 }]); expect(individual.days[0].color).toBe('#123456');
|
expect(individual.data).toEqual([{ day: '2026-09-04', value: 6 }]); expect(individual.days[0].color).toBe('#123456');
|
||||||
expect((await f.request(`/charts/${c.id}`, 'DELETE')).status).toBe(204);
|
expect((await f.request(`/charts/${c.id}`, 'DELETE')).status).toBe(204);
|
||||||
expect((await f.request(`/charts/${c.id}`)).status).toBe(404);
|
expect((await f.request(`/charts/${c.id}`)).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function scheduled(rule: Schedule, date: string): boolean {
|
|||||||
export function shade(ratio: number | null, steps: number, settings: CalendarSettings, exactSteps: boolean) {
|
export function shade(ratio: number | null, steps: number, settings: CalendarSettings, exactSteps: boolean) {
|
||||||
if (ratio === null) return { level: null, color: settings.notDueColor };
|
if (ratio === null) return { level: null, color: settings.notDueColor };
|
||||||
if (ratio <= 0) return { level: 0, color: settings.emptyColor };
|
if (ratio <= 0) return { level: 0, color: settings.emptyColor };
|
||||||
const total = exactSteps ? steps : settings.shadeCount;
|
const total = steps;
|
||||||
const level = exactSteps ? Math.min(total, Math.round(ratio * total)) : ratio >= 1 ? total : Math.min(total - (total > 1 ? 1 : 0), Math.max(1, Math.floor(ratio * total)));
|
const level = exactSteps ? Math.min(total, Math.round(ratio * total)) : ratio >= 1 ? total : Math.min(total - (total > 1 ? 1 : 0), Math.max(1, Math.floor(ratio * total)));
|
||||||
const intensity = total <= 1 ? 1 : 0.2 + 0.8 * (level - 1) / (total - 1);
|
const intensity = total <= 1 ? 1 : 0.2 + 0.8 * (level - 1) / (total - 1);
|
||||||
const color = '#' + [1, 3, 5].map(i => Math.round(255 * (1 - intensity) + parseInt(settings.mainColor.slice(i, i + 2), 16) * intensity).toString(16).padStart(2, '0')).join('');
|
const color = '#' + [1, 3, 5].map(i => Math.round(255 * (1 - intensity) + parseInt(settings.mainColor.slice(i, i + 2), 16) * intensity).toString(16).padStart(2, '0')).join('');
|
||||||
|
|||||||
42
src/habits/carryover.test.ts
Normal file
42
src/habits/carryover.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||||
|
import { fixture } from './test-fixture';
|
||||||
|
let f: ReturnType<typeof fixture>;
|
||||||
|
beforeEach(() => { f = fixture(); }); afterEach(() => f.close());
|
||||||
|
const create = (extra = {}) => f.json('/habits', 'POST', { name: 'Practice', method: 'count', target: 8, ...extra }, 201);
|
||||||
|
const day = (id: string, date: string) => f.json(`/habits/${id}/days/${date}`);
|
||||||
|
const log = (id: string, date: string, count: number) => f.json(`/habits/${id}/days/${date}/progress`, 'PUT', { count });
|
||||||
|
test('all default habits reset daily including partial counts and completed tasks', async () => {
|
||||||
|
const h = await create(); expect(h.carryPartialProgress).toBe(false); await log(h.id, '2026-09-04', 7);
|
||||||
|
const m = await f.json('/habits', 'POST', { name: 'Manual', method: 'manual' }, 201);
|
||||||
|
await f.json(`/habits/${m.id}/days/2026-09-04/progress`, 'PUT', { done: true });
|
||||||
|
const t = await f.json('/habits', 'POST', { name: 'Task', method: 'tasks', tasks: [{ name: 'One' }, { name: 'Two' }] }, 201);
|
||||||
|
await f.json(`/habits/${t.id}/days/2026-09-04/tasks/${t.tasks[0].id}`, 'PUT', { done: true });
|
||||||
|
f.setTime('2026-09-05T12:00Z');
|
||||||
|
for (const id of [h.id, m.id, t.id]) expect((await day(id, '2026-09-05')).value).toBe(0);
|
||||||
|
});
|
||||||
|
test('opt-in partial counts carry to the next due date and completed counts reset', async () => {
|
||||||
|
const h = await create({ carryPartialProgress: true, schedule: { type: 'interval', every: 3, anchor: '2026-09-04' } });
|
||||||
|
await log(h.id, '2026-09-04', 7); f.setTime('2026-09-07T12:00Z');
|
||||||
|
expect(await day(h.id, '2026-09-05')).toMatchObject({ due: false, value: 0 });
|
||||||
|
expect(await day(h.id, '2026-09-07')).toMatchObject({ value: 7, carriedFrom: '2026-09-04', loggedCount: null });
|
||||||
|
await log(h.id, '2026-09-07', 8); f.setTime('2026-09-10T12:00Z');
|
||||||
|
expect((await day(h.id, '2026-09-10')).value).toBe(0);
|
||||||
|
});
|
||||||
|
test('backfills recompute inherited values but never overwrite explicit daily logs', async () => {
|
||||||
|
const h = await create({ carryPartialProgress: true }); await log(h.id, '2026-09-04', 4);
|
||||||
|
f.setTime('2026-09-07T12:00Z'); expect((await day(h.id, '2026-09-07')).value).toBe(4);
|
||||||
|
await log(h.id, '2026-09-04', 7); expect((await day(h.id, '2026-09-06')).value).toBe(7);
|
||||||
|
await log(h.id, '2026-09-06', 2); await log(h.id, '2026-09-04', 1);
|
||||||
|
expect((await day(h.id, '2026-09-05')).value).toBe(1); expect((await day(h.id, '2026-09-07')).value).toBe(2);
|
||||||
|
await log(h.id, '2026-09-06', 0); expect((await day(h.id, '2026-09-07')).value).toBe(0);
|
||||||
|
});
|
||||||
|
test('carryover configuration is dated and method switches break inheritance', async () => {
|
||||||
|
const h = await create({ carryPartialProgress: true }); await log(h.id, '2026-09-04', 7);
|
||||||
|
f.setTime('2026-09-05T12:00Z'); expect((await day(h.id, '2026-09-05')).value).toBe(7);
|
||||||
|
await f.json(`/habits/${h.id}`, 'PATCH', { carryPartialProgress: false });
|
||||||
|
expect((await day(h.id, '2026-09-05')).value).toBe(0); expect((await day(h.id, '2026-09-04')).value).toBe(7);
|
||||||
|
await f.json(`/habits/${h.id}`, 'PATCH', { method: 'manual' });
|
||||||
|
f.setTime('2026-09-06T12:00Z'); await f.json(`/habits/${h.id}`, 'PATCH', { method: 'count', target: 8, carryPartialProgress: true });
|
||||||
|
expect((await day(h.id, '2026-09-06')).value).toBe(0);
|
||||||
|
expect((await f.request('/habits', 'POST', { name: 'No partial state', method: 'manual', carryPartialProgress: true })).status).toBe(422);
|
||||||
|
});
|
||||||
@@ -17,23 +17,23 @@ export const taskInput = z.object({ name, schedule }).strict();
|
|||||||
export const taskPatch = taskInput.partial().refine(v => Object.keys(v).length > 0);
|
export const taskPatch = taskInput.partial().refine(v => Object.keys(v).length > 0);
|
||||||
const common = { name, schedule };
|
const common = { name, schedule };
|
||||||
export const habitInput = z.discriminatedUnion('method', [
|
export const habitInput = z.discriminatedUnion('method', [
|
||||||
z.object({ ...common, method: z.literal('count'), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(),
|
z.object({ ...common, method: z.literal('count'), carryPartialProgress: z.boolean().default(false), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(),
|
||||||
z.object({ ...common, method: z.literal('manual') }).strict(),
|
z.object({ ...common, method: z.literal('manual') }).strict(),
|
||||||
z.object({ ...common, method: z.literal('tasks'), tasks: z.array(taskInput).max(100).default([]) }).strict(),
|
z.object({ ...common, method: z.literal('tasks'), tasks: z.array(taskInput).max(100).default([]) }).strict(),
|
||||||
]);
|
]);
|
||||||
export type HabitConfig = { name: string; schedule: Schedule; archived: boolean } & (
|
export type HabitConfig = { name: string; schedule: Schedule; archived: boolean } & (
|
||||||
{ method: 'count'; target: number; unit: string } |
|
{ method: 'count'; carryPartialProgress: boolean; target: number; unit: string } |
|
||||||
{ method: 'manual' } |
|
{ method: 'manual' } |
|
||||||
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
|
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
|
||||||
);
|
);
|
||||||
export const habitPatch = z.object({ name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
|
export const habitPatch = z.object({ carryPartialProgress: z.boolean().optional(), name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
|
||||||
export const progressInput = z.union([z.object({ count: z.number().int().min(0).max(1_000_000_000) }).strict(), z.object({ done: z.boolean() }).strict()]);
|
export const progressInput = z.union([z.object({ count: z.number().int().min(0).max(1_000_000_000) }).strict(), z.object({ done: z.boolean() }).strict()]);
|
||||||
export const doneInput = z.object({ done: z.boolean() }).strict();
|
export const doneInput = z.object({ done: z.boolean() }).strict();
|
||||||
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color');
|
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color');
|
||||||
// Product preferences only; layout and callbacks belong to the future UI.
|
// Product preferences only; layout and callbacks belong to the future UI.
|
||||||
export const calendarSettingsSchema = z.object({
|
export const calendarSettingsSchema = z.object({
|
||||||
mainColor: color.default('#196127'),
|
mainColor: color.default('#196127'),
|
||||||
shadeCount: z.number().int().min(2).max(20).default(4),
|
shadeCount: z.union([z.literal('auto'), z.number().int().min(2).max(20)]).default('auto'),
|
||||||
emptyColor: color.default('#ebedf0'),
|
emptyColor: color.default('#ebedf0'),
|
||||||
notDueColor: color.default('#f5f5f5'),
|
notDueColor: color.default('#f5f5f5'),
|
||||||
futureColor: color.default('#dbeafe'),
|
futureColor: color.default('#dbeafe'),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type HabitDay = typeof habitDays.$inferSelect;
|
|||||||
|
|
||||||
export class HabitService {
|
export class HabitService {
|
||||||
readonly today: string;
|
readonly today: string;
|
||||||
|
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
|
||||||
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
|
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
|
||||||
// Travel west must never reopen or rewrite earlier requirements.
|
// Travel west must never reopen or rewrite earlier requirements.
|
||||||
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
|
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
|
||||||
@@ -53,9 +54,10 @@ export class HabitService {
|
|||||||
const method = patch.method ?? old.method;
|
const method = patch.method ?? old.method;
|
||||||
const base: Record<string, unknown> = { name: patch.name ?? old.name, schedule: patch.schedule ?? old.schedule, method };
|
const base: Record<string, unknown> = { name: patch.name ?? old.name, schedule: patch.schedule ?? old.schedule, method };
|
||||||
if (method === 'count') {
|
if (method === 'count') {
|
||||||
|
base.carryPartialProgress = patch.carryPartialProgress ?? (old.method === 'count' ? old.carryPartialProgress : false);
|
||||||
base.target = patch.target ?? (old.method === 'count' ? old.target : undefined);
|
base.target = patch.target ?? (old.method === 'count' ? old.target : undefined);
|
||||||
base.unit = patch.unit ?? (old.method === 'count' ? old.unit : 'steps');
|
base.unit = patch.unit ?? (old.method === 'count' ? old.unit : 'steps');
|
||||||
} else if ('target' in patch || 'unit' in patch) throw new ApiError(422, 'Target and unit require the count method');
|
} else if ('target' in patch || 'unit' in patch || 'carryPartialProgress' in patch) throw new ApiError(422, 'Target, unit and carryPartialProgress require the count method');
|
||||||
if (method === 'tasks') base.tasks = [];
|
if (method === 'tasks') base.tasks = [];
|
||||||
const parsed = habitInput.safeParse(base);
|
const parsed = habitInput.safeParse(base);
|
||||||
if (!parsed.success) throw new ApiError(422, 'The selected method requires valid method-specific settings');
|
if (!parsed.success) throw new ApiError(422, 'The selected method requires valid method-specific settings');
|
||||||
@@ -65,6 +67,7 @@ export class HabitService {
|
|||||||
return this.revise(id, config);
|
return this.revise(id, config);
|
||||||
}
|
}
|
||||||
revise(id: string, config: HabitConfig) {
|
revise(id: string, config: HabitConfig) {
|
||||||
|
this.countCache.delete(id);
|
||||||
this.owned(id);
|
this.owned(id);
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
||||||
@@ -110,7 +113,7 @@ export class HabitService {
|
|||||||
const sameMethod = prior?.config.method === revision.config.method;
|
const sameMethod = prior?.config.method === revision.config.method;
|
||||||
const day = this.db.insert(habitDays).values({ habitId: habit.id, revisionId: revision.id, date,
|
const day = this.db.insert(habitDays).values({ habitId: habit.id, revisionId: revision.id, date,
|
||||||
timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? endOfDay(date, this.user.timezone),
|
timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? endOfDay(date, this.user.timezone),
|
||||||
count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get();
|
countSet: sameMethod ? existing!.countSet : false, count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get();
|
||||||
if (revision.config.method === 'tasks' && !revision.config.archived && scheduled(revision.config.schedule, date)) {
|
if (revision.config.method === 'tasks' && !revision.config.archived && scheduled(revision.config.schedule, date)) {
|
||||||
const previous = existing ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, existing.id)).all() : [];
|
const previous = existing ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, existing.id)).all() : [];
|
||||||
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
||||||
@@ -124,6 +127,23 @@ export class HabitService {
|
|||||||
}
|
}
|
||||||
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run();
|
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run();
|
||||||
}
|
}
|
||||||
|
private counts(id: string) {
|
||||||
|
const cached = this.countCache.get(id); if (cached) return cached;
|
||||||
|
const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays)
|
||||||
|
.innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id))
|
||||||
|
.where(and(eq(habitDays.habitId, id), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`))
|
||||||
|
.orderBy(asc(habitDays.date)).all();
|
||||||
|
const result = new Map<string, { value: number; carriedFrom: string | null }>();
|
||||||
|
let carry = 0; let source: string | null = null;
|
||||||
|
for (const { day, config } of rows) {
|
||||||
|
if (config.method !== 'count' || config.archived) { carry = 0; source = null; continue; }
|
||||||
|
if (!scheduled(config.schedule, day.date)) continue;
|
||||||
|
const value = day.countSet ? day.count : config.carryPartialProgress ? carry : 0;
|
||||||
|
result.set(day.date, { value, carriedFrom: !day.countSet && config.carryPartialProgress && carry > 0 ? source : null });
|
||||||
|
carry = value < config.target ? value : 0; source = carry > 0 ? day.date : null;
|
||||||
|
}
|
||||||
|
this.countCache.set(id, result); return result;
|
||||||
|
}
|
||||||
day(id: string, date: string) {
|
day(id: string, date: string) {
|
||||||
this.owned(id);
|
this.owned(id);
|
||||||
const revision = this.revision(id, date);
|
const revision = this.revision(id, date);
|
||||||
@@ -134,13 +154,14 @@ export class HabitService {
|
|||||||
? record ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, record.id)).orderBy(asc(taskOccurrences.id)).all()
|
? record ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, record.id)).orderBy(asc(taskOccurrences.id)).all()
|
||||||
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, updatedAt: null })) : [];
|
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, updatedAt: null })) : [];
|
||||||
const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0);
|
const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0);
|
||||||
const value = !due ? 0 : config!.method === 'count' ? record?.count ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
|
const counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined;
|
||||||
|
const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
|
||||||
const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
|
const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
|
||||||
const ratio = due ? Math.min(1, value / target!) : null;
|
const ratio = due ? Math.min(1, value / target!) : null;
|
||||||
return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
|
return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
|
||||||
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
|
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
|
||||||
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
|
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
|
||||||
value, target, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
|
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
|
||||||
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
|
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
|
||||||
}
|
}
|
||||||
writable(id: string, date: string) {
|
writable(id: string, date: string) {
|
||||||
@@ -154,9 +175,10 @@ export class HabitService {
|
|||||||
const { detail, record } = this.writable(id, date);
|
const { detail, record } = this.writable(id, date);
|
||||||
if (detail.method === 'tasks') throw new ApiError(409, 'Task habit progress is derived from occurrences');
|
if (detail.method === 'tasks') throw new ApiError(409, 'Task habit progress is derived from occurrences');
|
||||||
if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date');
|
if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date');
|
||||||
|
this.countCache.delete(id);
|
||||||
this.db.transaction(() => {
|
this.db.transaction(() => {
|
||||||
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: record.count, done: record.done }, after: input, createdAt: this.now }).run();
|
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: record.count, done: record.done }, after: input, createdAt: this.now }).run();
|
||||||
this.db.update(habitDays).set(input).where(eq(habitDays.id, record.id)).run();
|
this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
|
||||||
});
|
});
|
||||||
return this.day(id, date);
|
return this.day(id, date);
|
||||||
}
|
}
|
||||||
@@ -212,10 +234,12 @@ export class HabitService {
|
|||||||
const details = ids.map(id => this.day(id, date));
|
const details = ids.map(id => this.day(id, date));
|
||||||
const due = details.filter(d => d.due); const completed = due.filter(d => d.complete).length;
|
const due = details.filter(d => d.due); const completed = due.filter(d => d.complete).length;
|
||||||
const ratio = combined ? due.length ? completed / due.length : null : details[0]!.ratio;
|
const ratio = combined ? due.length ? completed / due.length : null : details[0]!.ratio;
|
||||||
const progress = shade(ratio, combined ? settings.shadeCount : details[0]!.target ?? 1, settings, !combined);
|
const manual = !combined && details[0]!.method === 'manual';
|
||||||
|
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
|
||||||
|
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
|
||||||
const future = date > this.today;
|
const future = date > this.today;
|
||||||
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
|
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
|
||||||
...progress, color: future ? settings.futureColor : progress.color, habits: details });
|
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details });
|
||||||
}
|
}
|
||||||
return { from, to, today: this.today, timezone: this.user.timezone, settings, days,
|
return { from, to, today: this.today, timezone: this.user.timezone, settings, days,
|
||||||
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
|
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
|
||||||
|
|||||||
Reference in New Issue
Block a user