Compare commits
350 Commits
contact-gi
...
integrate-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2deb84aa42 | ||
|
|
5528c44f2b | ||
|
|
28a825a460 | ||
|
|
b585c4d183 | ||
|
|
80d5199259 | ||
|
|
816c7a6582 | ||
|
|
9ea9f4969e | ||
| 5050156beb | |||
| d265a9f78c | |||
| f848de15f1 | |||
| ebaf2dedf0 | |||
|
|
749204f96b | ||
|
|
831532739c | ||
|
|
5f17f6cb4e | ||
|
|
5def44c349 | ||
| 1053bb6e4c | |||
| 88f46787e5 | |||
|
|
d9230d0be8 | ||
|
|
38f301f053 | ||
| e42552c67a | |||
|
|
45eff4a9ac | ||
|
|
ae5f1a33a7 | ||
|
|
95ac1afcd2 | ||
|
|
49c62b2b69 | ||
|
|
7ae3b241dd | ||
|
|
ced8248436 | ||
|
|
70059e5a31 | ||
| 0e3c6cb314 | |||
| 232b787b37 | |||
|
|
c06ffec466 | ||
|
|
8b199ec76c | ||
|
|
602fe394fa | ||
| 7e861e2fca | |||
| 73806e78bc | |||
|
|
d32cca4f53 | ||
|
|
1f858fa1ce | ||
|
|
f9446f529b | ||
|
|
d576920810 | ||
|
|
4004d9fe52 | ||
|
|
1bb3f52a30 | ||
|
|
2f99d0b416 | ||
|
|
9c3002f9c7 | ||
|
|
82fd7cddf7 | ||
|
|
10f2920e11 | ||
| 4b1a724246 | |||
|
|
d7db7731cf | ||
|
|
75c89b471c | ||
|
|
a804877a08 | ||
|
|
f7441f39e7 | ||
|
|
9628d5c8c6 | ||
|
|
b37051f25d | ||
|
|
7b87ab2a5c | ||
|
|
ca7ead224b | ||
|
|
bfc2f07326 | ||
|
|
562713d5a4 | ||
|
|
8100ee5be4 | ||
|
|
966ca8276d | ||
|
|
27e38f583b | ||
|
|
1e3ecf6d0f | ||
|
|
4d9435f257 | ||
| e8e00d3eae | |||
| 5c0ce2d1fb | |||
| 9e1c267bc0 | |||
| 723a0095a0 | |||
| 9a94843b68 | |||
| 9f3c62a29c | |||
| 39173a8db2 | |||
| 7ea6a2ef69 | |||
| f0f0f1681e | |||
|
|
2f1eeb6700 | ||
|
|
a353ed3c3e | ||
|
|
e048e4c86b | ||
|
|
16ed5131c4 | ||
|
|
e647af0777 | ||
| e6cc058935 | |||
|
|
ad51c187aa | ||
|
|
37cff0083f | ||
| 2049c9b6ec | |||
|
|
6fbc9c2a5b | ||
|
|
f186e129db | ||
|
|
455dfadb92 | ||
|
|
035509224b | ||
|
|
e9ea89edae | ||
| 1ce7c0486a | |||
| 637fc10e64 | |||
| 37d4dcc1a8 | |||
| c369c76c1a | |||
| 86caf793aa | |||
| 499fbd2cb3 | |||
| a4a9293bc2 | |||
| 9ac9f1d4a3 | |||
|
|
4f3a1b390d | ||
|
|
4de4fbecaf | ||
|
|
e3598992e7 | ||
|
|
ea19195850 | ||
|
|
ca545fd4b8 | ||
|
|
07b538cadc | ||
|
|
b84546686a | ||
|
|
461ee84d2a | ||
|
|
acf7d611e8 | ||
|
|
fface30123 | ||
| b0d13b3cd4 | |||
|
|
5256681089 | ||
|
|
225b34d480 | ||
| d9f9460be7 | |||
|
|
b1026a9854 | ||
|
|
cba33c6ad9 | ||
|
|
756688bf75 | ||
| 7599b37c01 | |||
| a4024537c2 | |||
| 6fe4f21ea8 | |||
| 97b382451a | |||
|
|
be8230d046 | ||
| 284fee9ded | |||
|
|
7fd2c4e0c7 | ||
|
|
20322789a2 | ||
|
|
666bed0efd | ||
|
|
7432525f4c | ||
|
|
88778a167c | ||
|
|
f4144c7469 | ||
|
|
eca6dfe9d7 | ||
| 530cddfab0 | |||
|
|
a6d282e59b | ||
|
|
088b9eff7f | ||
| 5340c00ae2 | |||
| ee587ac3fc | |||
| b3112a4086 | |||
| db4496c57b | |||
| a51fd90659 | |||
| 0c627f4822 | |||
| c7276f0b4d | |||
| d6524cbd43 | |||
| f5bea24921 | |||
| 46d7fee95e | |||
| c0f407eb72 | |||
| e8e0f315f8 | |||
| 1ea4608f0d | |||
| 2dc9b509ce | |||
| f4569d8b98 | |||
| 7575895f75 | |||
| 67a9ecf6c6 | |||
| 823fa51275 | |||
|
|
e2c2d54c20 | ||
|
|
6fd53b020e | ||
|
|
a3d6b458b1 | ||
|
|
b1fcb49e7c | ||
|
|
299762789b | ||
|
|
7a961af750 | ||
|
|
1790a6c5d6 | ||
|
|
1cbed4d1c2 | ||
|
|
2f495f6767 | ||
|
|
0fae8bbda6 | ||
| 297fe3cec6 | |||
| 2a932af806 | |||
| 28cea8f55b | |||
|
|
f31a76b816 | ||
|
|
5d9f455fc8 | ||
|
|
afe0f5e019 | ||
|
|
e0e8af3fff | ||
| c3ff471ea1 | |||
| 0072db1595 | |||
|
|
24ec81b0ba | ||
|
|
2c439ef439 | ||
|
|
0ca70b0f4e | ||
|
|
d01c6c2e9b | ||
|
|
2b3c83c21c | ||
|
|
8b8566c578 | ||
| a1e2d635f7 | |||
| f371ce88a0 | |||
|
|
69e29ecf85 | ||
|
|
23b97d483d | ||
|
|
4c218c4786 | ||
|
|
31f66909fa | ||
|
|
7917e707e9 | ||
|
|
a9fe862dda | ||
|
|
79b2f9a273 | ||
|
|
cf854d5054 | ||
|
|
8eb4ad5c74 | ||
|
|
eb77547ba1 | ||
|
|
616bef655a | ||
|
|
6da9e14b8a | ||
|
|
e856ace61f | ||
| 855448d07a | |||
|
|
5da1591ad8 | ||
|
|
b06e2b46f6 | ||
| 626071281f | |||
|
|
5fc5b958af | ||
| 69c922284e | |||
|
|
ac603f66e2 | ||
|
|
9bdd66b9c9 | ||
|
|
6fb4ceab81 | ||
|
|
7b40012df4 | ||
|
|
79cb52419e | ||
|
|
d6b5e13499 | ||
|
|
61117a0f03 | ||
|
|
e1cf27be05 | ||
|
|
ccb1f29df4 | ||
|
|
f55ef85981 | ||
|
|
d9569922eb | ||
| 8815f36596 | |||
| 631aa468e6 | |||
| ee29b517ce | |||
| f34c567ab4 | |||
| bd072d95eb | |||
| 030960dd59 | |||
|
|
72872935ae | ||
| b138441d10 | |||
|
|
a20c321a16 | ||
| c9cfeafd50 | |||
| 52b1e8ffa3 | |||
|
|
ca1190aa47 | ||
|
|
448d8a68d2 | ||
|
|
578dbe6177 | ||
|
|
704e495f5d | ||
|
|
04178bf9f8 | ||
|
|
b57be7670c | ||
|
|
10a1f435ed | ||
|
|
720be1aa4d | ||
|
|
4c761d8fd5 | ||
| de45e83ffb | |||
|
|
f38ec1daff | ||
|
|
ec2cab768b | ||
|
|
4cb1d8848f | ||
|
|
3e03aaf1e8 | ||
|
|
9ae9bed8a9 | ||
|
|
b2536adc4e | ||
|
|
22d6b08623 | ||
| ba587471f9 | |||
|
|
61703930f3 | ||
|
|
4c96a234e3 | ||
|
|
1a5aa7a5ef | ||
|
|
aa49a5d8a4 | ||
|
|
2db4f8f894 | ||
|
|
552de23ef2 | ||
|
|
2b423b8d7b | ||
|
|
8024688561 | ||
|
|
b374f2e5a1 | ||
| 9f1495e185 | |||
| f61cb6eea7 | |||
| 2f05d27b51 | |||
| 40c8189c51 | |||
| cd7755979f | |||
| 4fa8c8f4cb | |||
|
|
1eeb013638 | ||
|
|
3e5e2cd0bb | ||
|
|
d87f44b75d | ||
| 2c7cb9333e | |||
| fa8956fb38 | |||
|
|
1499211018 | ||
|
|
25e37cc415 | ||
|
|
d339f1a274 | ||
|
|
c2e7531554 | ||
| aa64f426f3 | |||
|
|
e6f0c7a079 | ||
| 2b9b43d08f | |||
|
|
5f8d1fc8c6 | ||
|
|
c9082fa57b | ||
| a7608429be | |||
|
|
a522a10fb7 | ||
|
|
b4e1313b22 | ||
| d3f54d6bff | |||
| 2bb733a9ea | |||
|
|
f63f4856bf | ||
|
|
eb4ddaba50 | ||
|
|
971bc68a74 | ||
|
|
d2e04fe2a0 | ||
| 7da6f722f5 | |||
|
|
18ca6baded | ||
| 475f4d5ce5 | |||
|
|
ae4e9b3420 | ||
|
|
0bda040f15 | ||
|
|
a2e6ae5c28 | ||
| 24a7cf5eb6 | |||
| da0621c09a | |||
|
|
4a22a35b3e | ||
|
|
95b0cbca78 | ||
|
|
1227cdee76 | ||
|
|
4a1249d166 | ||
|
|
6225cd7f8f | ||
|
|
fad7093fbd | ||
|
|
dde37e73e1 | ||
|
|
83c0c18db2 | ||
|
|
fddb2ac959 | ||
|
|
40babae05d | ||
|
|
5780d96cdc | ||
|
|
e67c97821a | ||
|
|
40fa38a9ce | ||
|
|
acbc276ef6 | ||
| ff864adbe5 | |||
|
|
96e4d3c394 | ||
|
|
c4f2bb5e3a | ||
|
|
f51408e32a | ||
|
|
649786ae01 | ||
|
|
4aea8d9ed3 | ||
|
|
0079ca252d | ||
|
|
8827c4a973 | ||
| 6f9847b524 | |||
| 01279b61f5 | |||
|
|
98f97f2dc9 | ||
|
|
4c7c2d48e9 | ||
| 43e7bc1c12 | |||
|
|
1a77dfb750 | ||
|
|
1365adad92 | ||
|
|
baccb962cf | ||
|
|
0a0a17ef9c | ||
| aa346a9abd | |||
| 9ea2f96106 | |||
| 623bf12ecd | |||
|
|
427660d686 | ||
|
|
643f31c43a | ||
|
|
8dab4ed016 | ||
|
|
4f78bfe744 | ||
| 2c6b787fa2 | |||
|
|
ceceabf7b5 | ||
|
|
9386b2e96f | ||
|
|
128ddff467 | ||
|
|
b834596ba6 | ||
|
|
77a4c60656 | ||
|
|
a11443dc3a | ||
|
|
7f7680f4a6 | ||
|
|
271a45afa3 | ||
|
|
6aac3ca35f | ||
|
|
f0fd8c0f12 | ||
|
|
fd30343ec4 | ||
|
|
e70faff5ce | ||
|
|
9512e8192f | ||
|
|
a6126ecac3 | ||
| 528a68ef6c | |||
| 8991b36a56 | |||
| 6f5661d61c | |||
|
|
d66d8ce1c1 | ||
|
|
277fe49aa8 | ||
|
|
a85b508f44 | ||
|
|
be4ab16b00 | ||
|
|
1305eed9bc | ||
|
|
aa55588cbb | ||
|
|
5f63e05090 | ||
|
|
4391cb2881 | ||
| 0b9c243969 | |||
|
|
74c70c7fa0 | ||
|
|
3be7001d1b | ||
|
|
95a8f5ebe1 | ||
|
|
e3cc22245c | ||
|
|
f31eb5f6c9 | ||
|
|
9f976f011a | ||
|
|
e5ad71505c | ||
|
|
f2026bb921 | ||
| 19f0c270d3 | |||
|
|
693173f09d | ||
|
|
a1388539c1 |
@@ -104,6 +104,161 @@ High-level meta-rules that bundle related sub-rules for specific workflows.
|
||||
- **`meta_bug_diagnosis.mdc`** - Bug investigation workflow bundling
|
||||
- **`meta_bug_fixing.mdc`** - Bug fix implementation workflow bundling
|
||||
- **`meta_feature_implementation.mdc`** - Feature implementation workflow bundling
|
||||
- **`meta_research.mdc`** - Investigation and research workflow bundling
|
||||
|
||||
### **Workflow State Management**
|
||||
|
||||
The project uses a sophisticated workflow state management system to ensure systematic development processes and maintain code quality across all phases of development.
|
||||
|
||||
#### **Workflow State System**
|
||||
|
||||
The workflow state is managed through `.cursor/rules/.workflow_state.json` and enforces different modes with specific constraints. The system automatically tracks workflow progression and maintains a complete history of mode transitions.
|
||||
|
||||
**Available Modes**:
|
||||
- **`diagnosis`** - Investigation and analysis phase (read-only)
|
||||
- **`fixing`** - Implementation and bug fixing phase (full access)
|
||||
- **`planning`** - Design and architecture phase (design only)
|
||||
- **`research`** - Investigation and research phase (investigation only)
|
||||
- **`documentation`** - Documentation writing phase (writing only)
|
||||
|
||||
**Mode Constraints**:
|
||||
```json
|
||||
{
|
||||
"diagnosis": {
|
||||
"mode": "read_only",
|
||||
"forbidden": ["modify", "create", "build", "commit"],
|
||||
"allowed": ["read", "search", "analyze", "document"]
|
||||
},
|
||||
"fixing": {
|
||||
"mode": "implementation",
|
||||
"forbidden": [],
|
||||
"allowed": ["modify", "create", "build", "commit", "test"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Workflow History Tracking**:
|
||||
|
||||
The system automatically maintains a `workflowHistory` array that records all mode transitions and meta-rule invocations:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowHistory": [
|
||||
{
|
||||
"mode": "research",
|
||||
"invoked": "meta_core_always_on.mdc",
|
||||
"timestamp": "2025-08-25T02:14:37Z"
|
||||
},
|
||||
{
|
||||
"mode": "diagnosis",
|
||||
"invoked": "meta_bug_diagnosis.mdc",
|
||||
"timestamp": "2025-08-25T02:14:37Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**History Entry Format**:
|
||||
- **`mode`**: The workflow mode that was activated
|
||||
- **`invoked`**: The specific meta-rule that triggered the mode change
|
||||
- **`timestamp`**: UTC timestamp when the mode transition occurred
|
||||
|
||||
**History Purpose**:
|
||||
- **Workflow Continuity**: Track progression through development phases
|
||||
- **Meta-Rule Usage**: Monitor which rules are invoked and when
|
||||
- **Temporal Context**: Maintain chronological order of workflow changes
|
||||
- **State Persistence**: Preserve workflow history across development sessions
|
||||
- **Debugging Support**: Help diagnose workflow state issues
|
||||
- **Process Analysis**: Understand development patterns and meta-rule effectiveness
|
||||
|
||||
#### **Commit Override System**
|
||||
|
||||
The workflow includes a flexible commit override mechanism that allows commits on demand while maintaining workflow integrity:
|
||||
|
||||
```json
|
||||
{
|
||||
"overrides": {
|
||||
"commit": {
|
||||
"allowed": true,
|
||||
"requires_override": true,
|
||||
"override_reason": "user_requested"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Override Benefits**:
|
||||
- ✅ **Investigation Commits**: Document findings during diagnosis phases
|
||||
- ✅ **Work-in-Progress**: Commit partial solutions during complex investigations
|
||||
- ✅ **Emergency Fixes**: Commit critical fixes without mode transitions
|
||||
- ✅ **Flexible Workflow**: Maintain systematic approach while accommodating real needs
|
||||
|
||||
**Override Limitations**:
|
||||
- ❌ **Does NOT bypass**: Version control rules, commit message standards, or security requirements
|
||||
- ❌ **Does NOT bypass**: Code quality standards, testing requirements, or documentation requirements
|
||||
|
||||
#### **Workflow Enforcement**
|
||||
|
||||
The system automatically enforces workflow constraints through the core always-on rules:
|
||||
|
||||
**Before Every Interaction**:
|
||||
1. **Read current workflow state** from `.cursor/rules/.workflow_state.json`
|
||||
2. **Identify current mode** and its constraints
|
||||
3. **Validate user request** against current mode constraints
|
||||
4. **Enforce constraints** before generating response
|
||||
5. **Guide model behavior** based on current mode
|
||||
|
||||
**Mode-Specific Enforcement**:
|
||||
- **Diagnosis Mode**: Blocks modification, creation, building, and commits
|
||||
- **Fixing Mode**: Allows full implementation and testing capabilities
|
||||
- **Planning Mode**: Focuses on design and architecture, blocks implementation
|
||||
- **Research Mode**: Enables investigation and analysis, blocks modification
|
||||
- **Documentation Mode**: Allows writing and editing, blocks implementation
|
||||
|
||||
#### **Workflow Transitions**
|
||||
|
||||
To change workflow modes, invoke the appropriate meta-rule:
|
||||
|
||||
```bash
|
||||
# Switch to bug fixing mode
|
||||
@meta_bug_fixing.mdc
|
||||
|
||||
# Switch to feature planning mode
|
||||
@meta_feature_planning.mdc
|
||||
|
||||
# Switch to documentation mode
|
||||
@meta_documentation.mdc
|
||||
```
|
||||
|
||||
**Transition Requirements**:
|
||||
- **Mode Changes**: Require explicit meta-rule invocation
|
||||
- **State Updates**: Automatically update workflow state file
|
||||
- **Constraint Enforcement**: Immediately apply new mode constraints
|
||||
- **History Tracking**: Automatically maintained in `workflowHistory` array
|
||||
- **Timestamp Recording**: Each transition recorded with UTC timestamp
|
||||
|
||||
#### **Integration with Development Process**
|
||||
|
||||
The workflow system integrates seamlessly with existing development practices:
|
||||
|
||||
**Version Control**:
|
||||
- All commits must follow TimeSafari commit message standards
|
||||
- Security audit checklists are enforced regardless of workflow mode
|
||||
- Documentation updates are required for substantial changes
|
||||
|
||||
**Quality Assurance**:
|
||||
- Code quality standards (PEP8, TypeScript, etc.) are always enforced
|
||||
- Testing requirements apply to all implementation work
|
||||
- Documentation standards are maintained across all phases
|
||||
|
||||
**Build System**:
|
||||
- Build Architecture Guard protects critical build files
|
||||
- Platform-specific build processes respect workflow constraints
|
||||
- Asset generation follows established patterns
|
||||
|
||||
**Migration Context**:
|
||||
- Database migration work respects investigation vs. implementation phases
|
||||
- Component migration progress is tracked through workflow states
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
|
||||
192
.cursor/rules/always_on_rules.mdc
Normal file
192
.cursor/rules/always_on_rules.mdc
Normal file
@@ -0,0 +1,192 @@
|
||||
# Meta-Rule: Core Always-On Rules
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-08-21
|
||||
**Status**: 🎯 **ACTIVE** - Core rules for every prompt
|
||||
|
||||
## Purpose
|
||||
|
||||
This meta-rule bundles the core rules that should be applied to **every single
|
||||
prompt** because they define fundamental behaviors, principles, and context
|
||||
that are essential for all AI interactions.
|
||||
|
||||
## When to Use
|
||||
|
||||
**ALWAYS** - These rules apply to every single prompt, regardless of the task
|
||||
or context. They form the foundation for all AI assistant behavior.
|
||||
|
||||
## Bundled Rules
|
||||
|
||||
### **Core Human Competence Principles**
|
||||
|
||||
- **`core/base_context.mdc`** - Human competence first principles, interaction
|
||||
guidelines, and output contract requirements
|
||||
- **`core/less_complex.mdc`** - Minimalist solution principle and complexity
|
||||
guidelines
|
||||
|
||||
### **Time & Context Standards**
|
||||
|
||||
- **`development/time.mdc`** - Time handling principles and UTC standards
|
||||
- **`development/time_examples.mdc`** - Practical time implementation examples
|
||||
- **`development/time_implementation.mdc`** - Detailed time implementation
|
||||
guidelines
|
||||
|
||||
### **Version Control & Process**
|
||||
|
||||
- **`workflow/version_control.mdc`** - Version control principles and commit
|
||||
guidelines
|
||||
- **`workflow/commit_messages.mdc`** - Commit message format and conventions
|
||||
|
||||
### **Application Context**
|
||||
|
||||
- **`app/timesafari.mdc`** - Core TimeSafari application context and
|
||||
development principles
|
||||
- **`app/timesafari_development.mdc`** - TimeSafari-specific development
|
||||
workflow and quality standards
|
||||
|
||||
## Why These Rules Are Always-On
|
||||
|
||||
### **Base Context**
|
||||
|
||||
- **Human Competence First**: Every interaction must increase human competence
|
||||
- **Output Contract**: All responses must follow the required structure
|
||||
- **Competence Hooks**: Learning and collaboration must be built into every response
|
||||
|
||||
### **Time Standards**
|
||||
|
||||
- **UTC Consistency**: All timestamps must use UTC for system operations
|
||||
- **Evidence Collection**: Time context is essential for debugging and investigation
|
||||
- **Cross-Platform**: Time handling affects all platforms and features
|
||||
|
||||
### **Version Control**
|
||||
|
||||
- **Commit Standards**: Every code change must follow commit message conventions
|
||||
- **Process Consistency**: Version control affects all development work
|
||||
- **Team Collaboration**: Commit standards enable effective team communication
|
||||
|
||||
### **Application Context**
|
||||
|
||||
- **Platform Awareness**: Every task must consider web/mobile/desktop platforms
|
||||
- **Architecture Principles**: All work must follow TimeSafari patterns
|
||||
- **Development Standards**: Quality and testing requirements apply to all work
|
||||
|
||||
## Application Priority
|
||||
|
||||
### **Primary (Apply First)**
|
||||
|
||||
1. **Base Context** - Human competence and output contract
|
||||
2. **Time Standards** - UTC and timestamp requirements
|
||||
3. **Application Context** - TimeSafari principles and platforms
|
||||
|
||||
### **Secondary (Apply as Needed)**
|
||||
|
||||
1. **Version Control** - When making code changes
|
||||
2. **Complexity Guidelines** - When evaluating solution approaches
|
||||
|
||||
## Integration with Other Meta-Rules
|
||||
|
||||
### **Feature Planning**
|
||||
|
||||
- Base context ensures human competence focus
|
||||
- Time standards inform planning and estimation
|
||||
- Application context drives platform considerations
|
||||
|
||||
### **Bug Diagnosis**
|
||||
|
||||
- Base context ensures systematic investigation
|
||||
- Time standards enable proper evidence collection
|
||||
- Application context provides system understanding
|
||||
|
||||
### **Bug Fixing**
|
||||
|
||||
- Base context ensures quality implementation
|
||||
- Time standards maintain logging consistency
|
||||
- Application context guides testing strategy
|
||||
|
||||
### **Feature Implementation**
|
||||
|
||||
- Base context ensures proper development approach
|
||||
- Time standards maintain system consistency
|
||||
- Application context drives architecture decisions
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] **Base context applied** to every single prompt
|
||||
- [ ] **Time standards followed** for all timestamps and logging
|
||||
- [ ] **Version control standards** applied to all code changes
|
||||
- [ ] **Application context considered** for all platform work
|
||||
- [ ] **Human competence focus** maintained in all interactions
|
||||
- [ ] **Output contract structure** followed in all responses
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Don't skip base context** - loses human competence focus
|
||||
- **Don't ignore time standards** - creates inconsistent timestamps
|
||||
- **Don't forget application context** - misses platform considerations
|
||||
- **Don't skip version control** - creates inconsistent commit history
|
||||
- **Don't lose competence focus** - reduces learning value
|
||||
|
||||
## Feedback & Improvement
|
||||
|
||||
### **Rule Effectiveness Ratings (1-5 scale)**
|
||||
|
||||
- **Base Context**: ___/5 - Comments: _______________
|
||||
- **Time Standards**: ___/5 - Comments: _______________
|
||||
- **Version Control**: ___/5 - Comments: _______________
|
||||
- **Application Context**: ___/5 - Comments: _______________
|
||||
|
||||
### **Always-On Effectiveness**
|
||||
|
||||
- **Consistency**: Are these rules applied consistently across all prompts?
|
||||
- **Value**: Do these rules add value to every interaction?
|
||||
- **Overhead**: Are these rules too burdensome for simple tasks?
|
||||
|
||||
### **Integration Feedback**
|
||||
|
||||
- **With Other Meta-Rules**: How well do these integrate with workflow rules?
|
||||
- **Context Switching**: Do these rules help or hinder context switching?
|
||||
- **Learning Curve**: Are these rules easy for new users to understand?
|
||||
|
||||
### **Overall Experience**
|
||||
|
||||
- **Quality Improvement**: Do these rules improve response quality?
|
||||
- **Efficiency**: Do these rules make interactions more efficient?
|
||||
- **Recommendation**: Would you recommend keeping these always-on?
|
||||
|
||||
## Model Implementation Checklist
|
||||
|
||||
### Before Every Prompt
|
||||
|
||||
- [ ] **Base Context**: Ensure human competence principles are active
|
||||
- [ ] **Time Standards**: Verify UTC and timestamp requirements are clear
|
||||
- [ ] **Application Context**: Confirm TimeSafari context is loaded
|
||||
- [ ] **Version Control**: Prepare commit standards if code changes are needed
|
||||
|
||||
### During Response Creation
|
||||
|
||||
- [ ] **Output Contract**: Follow required response structure
|
||||
- [ ] **Competence Hooks**: Include learning and collaboration elements
|
||||
- [ ] **Time Consistency**: Apply UTC standards for all time references
|
||||
- [ ] **Platform Awareness**: Consider all target platforms
|
||||
|
||||
### After Response Creation
|
||||
|
||||
- [ ] **Validation**: Verify all always-on rules were applied
|
||||
- [ ] **Quality Check**: Ensure response meets competence standards
|
||||
- [ ] **Context Review**: Confirm application context was properly considered
|
||||
- [ ] **Feedback Collection**: Note any issues with always-on application
|
||||
|
||||
---
|
||||
|
||||
**See also**:
|
||||
|
||||
- `.cursor/rules/meta_feature_planning.mdc` for workflow-specific rules
|
||||
- `.cursor/rules/meta_bug_diagnosis.mdc` for investigation workflows
|
||||
- `.cursor/rules/meta_bug_fixing.mdc` for fix implementation
|
||||
- `.cursor/rules/meta_feature_implementation.mdc` for feature development
|
||||
|
||||
**Status**: Active core always-on meta-rule
|
||||
**Priority**: Critical (applies to every prompt)
|
||||
**Estimated Effort**: Ongoing reference
|
||||
**Dependencies**: All bundled sub-rules
|
||||
**Stakeholders**: All AI interactions, Development team
|
||||
@@ -12,6 +12,7 @@ language: Match repository languages and conventions
|
||||
|
||||
## Rules
|
||||
|
||||
0. **Principle:** just the facts m'am.
|
||||
1. **Default to the least complex solution.** Fix the problem directly
|
||||
where it occurs; avoid new layers, indirection, or patterns unless
|
||||
strictly necessary.
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
globs: **/src/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
✅ use system date command to timestamp all interactions with accurate date and
|
||||
✅ use system date command to timestamp all documentation with accurate date and
|
||||
time
|
||||
✅ python script files must always have a blank line at their end
|
||||
✅ remove whitespace at the end of lines
|
||||
✅ use npm run lint-fix to check for warnings
|
||||
✅ do not use npm run dev let me handle running and supplying feedback
|
||||
@@ -22,12 +21,10 @@ alwaysApply: false
|
||||
|
||||
- [ ] **Timestamp Usage**: Include accurate timestamps in all interactions
|
||||
- [ ] **Code Quality**: Use npm run lint-fix to check for warnings
|
||||
- [ ] **File Standards**: Ensure Python files have blank line at end
|
||||
- [ ] **Whitespace**: Remove trailing whitespace from all lines
|
||||
|
||||
### After Development
|
||||
|
||||
- [ ] **Linting Check**: Run npm run lint-fix to verify code quality
|
||||
- [ ] **File Validation**: Confirm Python files end with blank line
|
||||
- [ ] **Whitespace Review**: Verify no trailing whitespace remains
|
||||
- [ ] **Documentation**: Update relevant documentation with changes
|
||||
|
||||
206
.cursor/rules/harbor_pilot_universal.mdc
Normal file
206
.cursor/rules/harbor_pilot_universal.mdc
Normal file
@@ -0,0 +1,206 @@
|
||||
---
|
||||
alwaysApply: false
|
||||
---
|
||||
```json
|
||||
{
|
||||
"coaching_level": "standard",
|
||||
"socratic_max_questions": 2,
|
||||
"verbosity": "concise",
|
||||
"timebox_minutes": 10,
|
||||
"format_enforcement": "strict"
|
||||
}
|
||||
```
|
||||
|
||||
# Harbor Pilot — Universal Directive for Human-Facing Technical Guides
|
||||
|
||||
**Author**: System/Shared
|
||||
**Date**: 2025-08-21 (UTC)
|
||||
**Status**: 🚢 ACTIVE — General ruleset extending *Base Context — Human Competence First*
|
||||
|
||||
> **Alignment with Base Context**
|
||||
> - **Purpose fit**: Prioritizes human competence and collaboration while delivering reproducible artifacts.
|
||||
> - **Output Contract**: This directive **adds universal constraints** for any technical topic while **inheriting** the Base Context contract sections.
|
||||
> - **Toggles honored**: Uses the same toggle semantics; defaults above can be overridden by the caller.
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
Produce a **developer-grade, reproducible guide** for any technical topic that onboards a competent practitioner **without meta narration** and **with evidence-backed steps**.
|
||||
|
||||
## Scope & Constraints
|
||||
- **One Markdown document** as the deliverable.
|
||||
- Use **absolute dates** in **UTC** (e.g., `2025-08-21T14:22Z`) — avoid “today/yesterday”.
|
||||
- Include at least **one diagram** (Mermaid preferred). Choose the most fitting type:
|
||||
- `sequenceDiagram` (protocols/flows), `flowchart`, `stateDiagram`, `gantt` (timelines), or `classDiagram` (schemas).
|
||||
- Provide runnable examples where applicable:
|
||||
- **APIs**: `curl` + one client library (e.g., `httpx` for Python).
|
||||
- **CLIs**: literal command blocks and expected output snippets.
|
||||
- **Code**: minimal, self-contained samples (language appropriate).
|
||||
- Cite **evidence** for *Works/Doesn’t* items (timestamps, filenames, line numbers, IDs/status codes, or logs).
|
||||
- If something is unknown, output `TODO:<missing>` — **never invent**.
|
||||
|
||||
## Required Sections (extends Base Output Contract)
|
||||
Follow this exact order **after** the Base Contract’s **Objective → Result → Use/Run** headers:
|
||||
|
||||
1. **Context & Scope**
|
||||
- Problem statement, audience, in/out-of-scope bullets.
|
||||
2. **Artifacts & Links**
|
||||
- Repos/PRs, design docs, datasets/HARs/pcaps, scripts/tools, dashboards.
|
||||
3. **Environment & Preconditions**
|
||||
- OS/runtime, versions/build IDs, services/endpoints/URLs, credentials/auth mode (describe acquisition, do not expose secrets).
|
||||
4. **Architecture / Process Overview**
|
||||
- Short prose + **one diagram** selected from the list above.
|
||||
5. **Interfaces & Contracts (choose one)**
|
||||
- **API-based**: Endpoint table (*Step, Method, Path/URL, Auth, Key Headers/Params, Sample Req/Resp ref*).
|
||||
- **Data/Files**: I/O contract table (*Source, Format, Schema/Columns, Size, Validation rules*).
|
||||
- **Systems/Hardware**: Interfaces table (*Port/Bus, Protocol, Voltage/Timing, Constraints*).
|
||||
6. **Repro: End-to-End Procedure**
|
||||
- Minimal copy-paste steps with code/commands and **expected outputs**.
|
||||
7. **What Works (with Evidence)**
|
||||
- Each item: **Time (UTC)** • **Artifact/Req IDs** • **Status/Result** • **Where to verify**.
|
||||
8. **What Doesn’t (Evidence & Hypotheses)**
|
||||
- Each failure: locus (file/endpoint/module), evidence snippet; short hypothesis and **next probe**.
|
||||
9. **Risks, Limits, Assumptions**
|
||||
- SLOs/limits, rate/size caps, security boundaries (CORS/CSRF/ACLs), retries/backoff/idempotency patterns.
|
||||
10. **Next Steps (Owner • Exit Criteria • Target Date)**
|
||||
- Actionable, assigned, and time-bound.
|
||||
11. **References**
|
||||
- Canonical docs, specs, tickets, prior analyses.
|
||||
|
||||
> **Competence Hooks (per Base Context; keep lightweight):**
|
||||
> - *Why this works* (≤3 bullets) — core invariants or guarantees.
|
||||
> - *Common pitfalls* (≤3 bullets) — the traps we saw in evidence.
|
||||
> - *Next skill unlock* (1 line) — the next capability to implement/learn.
|
||||
> - *Teach-back* (1 line) — prompt the reader to restate the flow/architecture.
|
||||
|
||||
> **Collaboration Hooks (per Base Context):**
|
||||
> - Name reviewers for **Interfaces & Contracts** and the **diagram**.
|
||||
> - Short **sign-off checklist** before merging/publishing the guide.
|
||||
|
||||
## Do / Don’t (Base-aligned)
|
||||
- **Do** quantify progress only against a defined scope with acceptance criteria.
|
||||
- **Do** include minimal sample payloads/headers or I/O schemas; redact sensitive values.
|
||||
- **Do** keep commentary lean; if timeboxed, move depth to **Deferred for depth**.
|
||||
- **Don’t** use marketing language or meta narration (“Perfect!”, “tool called”, “new chat”).
|
||||
- **Don’t** include IDE-specific chatter or internal rules unrelated to the task.
|
||||
|
||||
## Validation Checklist (self-check before returning)
|
||||
- [ ] All Required Sections present and ordered.
|
||||
- [ ] Diagram compiles (basic Mermaid syntax) and fits the problem.
|
||||
- [ ] If API-based, **Auth** and **Key Headers/Params** are listed for each endpoint.
|
||||
- [ ] Repro section includes commands/code **and expected outputs**.
|
||||
- [ ] Every Works/Doesn’t item has **UTC timestamp**, **status/result**, and **verifiable evidence**.
|
||||
- [ ] Next Steps include **Owner**, **Exit Criteria**, **Target Date**.
|
||||
- [ ] Unknowns are `TODO:<missing>` — no fabrication.
|
||||
- [ ] Base **Output Contract** sections satisfied (Objective/Result/Use/Run/Competence/Collaboration/Assumptions/References).
|
||||
|
||||
## Universal Template (fill-in)
|
||||
```markdown
|
||||
# <Title> — Working Notes (As of YYYY-MM-DDTHH:MMZ)
|
||||
|
||||
## Objective
|
||||
<one line>
|
||||
|
||||
## Result
|
||||
<link to the produced guide file or say “this document”>
|
||||
|
||||
## Use/Run
|
||||
<how to apply/test and where to run samples>
|
||||
|
||||
## Context & Scope
|
||||
- Audience: <role(s)>
|
||||
- In scope: <bullets>
|
||||
- Out of scope: <bullets>
|
||||
|
||||
## Artifacts & Links
|
||||
- Repo/PR: <link>
|
||||
- Data/Logs: <paths or links>
|
||||
- Scripts/Tools: <paths>
|
||||
- Dashboards: <links>
|
||||
|
||||
## Environment & Preconditions
|
||||
- OS/Runtime: <details>
|
||||
- Versions/Builds: <list>
|
||||
- Services/Endpoints: <list>
|
||||
- Auth mode: <Bearer/Session/Keys + how acquired>
|
||||
|
||||
## Architecture / Process Overview
|
||||
<short prose>
|
||||
```mermaid
|
||||
<one suitable diagram: sequenceDiagram | flowchart | stateDiagram | gantt | classDiagram>
|
||||
```
|
||||
|
||||
## Interfaces & Contracts
|
||||
### If API-based
|
||||
| Step | Method | Path/URL | Auth | Key Headers/Params | Sample |
|
||||
|---|---|---|---|---|---|
|
||||
| <…> | <…> | <…> | <…> | <…> | below |
|
||||
|
||||
### If Data/Files
|
||||
| Source | Format | Schema/Columns | Size | Validation |
|
||||
|---|---|---|---|---|
|
||||
| <…> | <…> | <…> | <…> | <…> |
|
||||
|
||||
### If Systems/Hardware
|
||||
| Interface | Protocol | Timing/Voltage | Constraints | Notes |
|
||||
|---|---|---|---|---|
|
||||
| <…> | <…> | <…> | <…> | <…> |
|
||||
|
||||
## Repro: End-to-End Procedure
|
||||
```bash
|
||||
# commands / curl examples (redacted where necessary)
|
||||
```
|
||||
```python
|
||||
# minimal client library example (language appropriate)
|
||||
```
|
||||
> Expected output: <snippet/checks>
|
||||
|
||||
## What Works (Evidence)
|
||||
- ✅ <short statement>
|
||||
- **Time**: <YYYY-MM-DDTHH:MMZ>
|
||||
- **Evidence**: file/line/log or request id/status
|
||||
- **Verify at**: <where>
|
||||
|
||||
## What Doesn’t (Evidence & Hypotheses)
|
||||
- ❌ <short failure> at `<component/endpoint/file>`
|
||||
- **Time**: <YYYY-MM-DDTHH:MMZ>
|
||||
- **Evidence**: <snippet/id/status>
|
||||
- **Hypothesis**: <short>
|
||||
- **Next probe**: <short>
|
||||
|
||||
## Risks, Limits, Assumptions
|
||||
<bullets: limits, security boundaries, retries/backoff, idempotency, SLOs>
|
||||
|
||||
## Next Steps
|
||||
| Owner | Task | Exit Criteria | Target Date (UTC) |
|
||||
|---|---|---|---|
|
||||
| <name> | <action> | <measurable outcome> | <YYYY-MM-DD> |
|
||||
|
||||
## References
|
||||
<links/titles>
|
||||
|
||||
## Competence Hooks
|
||||
- *Why this works*: <≤3 bullets>
|
||||
- *Common pitfalls*: <≤3 bullets>
|
||||
- *Next skill unlock*: <1 line>
|
||||
- *Teach-back*: <1 line>
|
||||
|
||||
## Collaboration Hooks
|
||||
- Reviewers: <names/roles>
|
||||
- Sign-off checklist: <≤5 checks>
|
||||
|
||||
## Assumptions & Limits
|
||||
<bullets>
|
||||
|
||||
## Deferred for depth
|
||||
<park deeper material here to respect timeboxing>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Notes for Implementers:**
|
||||
- Respect Base *Do-Not* (no filler, no invented facts, no censorship).
|
||||
- Prefer clarity over completeness when timeboxed; capture unknowns explicitly.
|
||||
- Apply historical comment management rules (see `.cursor/rules/historical_comment_management.mdc`)
|
||||
- Apply realistic time estimation rules (see `.cursor/rules/realistic_time_estimation.mdc`)
|
||||
- Apply Playwright test investigation rules (see `.cursor/rules/playwright_test_investigation.mdc`)
|
||||
@@ -1,169 +1,285 @@
|
||||
# Meta-Rule: Bug Diagnosis
|
||||
# Meta-Rule: Bug Diagnosis Workflow
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-08-21
|
||||
**Status**: 🎯 **ACTIVE** - Bug investigation workflow bundling
|
||||
**Date**: August 24, 2025
|
||||
**Status**: 🎯 **ACTIVE** - Core workflow for all bug investigation
|
||||
|
||||
## Purpose
|
||||
|
||||
This meta-rule bundles all the rules needed for systematic bug investigation
|
||||
and root cause analysis. Use this when bugs are reported, performance
|
||||
issues occur, or unexpected behavior happens.
|
||||
This meta-rule defines the systematic approach for investigating and diagnosing
|
||||
bugs, defects, and unexpected behaviors in the TimeSafari application. It ensures
|
||||
consistent, thorough, and efficient problem-solving workflows.
|
||||
|
||||
## Workflow Constraints
|
||||
|
||||
**This meta-rule enforces DIAGNOSIS MODE for all bundled sub-rules:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowMode": "diagnosis",
|
||||
"constraints": {
|
||||
"mode": "read_only",
|
||||
"forbidden": ["modify", "create", "build", "commit"],
|
||||
"required": "complete_investigation_before_fixing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**All bundled sub-rules automatically inherit these constraints.**
|
||||
|
||||
## Workflow State Update
|
||||
|
||||
**When this meta-rule is invoked, update the workflow state file:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentMode": "diagnosis",
|
||||
"lastInvoked": "meta_bug_diagnosis.mdc",
|
||||
"timestamp": "2025-01-27T15:30:00Z",
|
||||
"constraints": {
|
||||
"mode": "read_only",
|
||||
"forbidden": ["modify", "create", "build", "commit"],
|
||||
"allowed": ["read", "search", "analyze", "document"],
|
||||
"required": "complete_investigation_before_fixing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**State File Location**: `.cursor/rules/.workflow_state.json`
|
||||
|
||||
**This enables the core always-on rule to enforce diagnosis mode constraints.**
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Bug Reports**: Investigating reported bugs or issues
|
||||
- **Performance Issues**: Diagnosing slow performance or bottlenecks
|
||||
- **Unexpected Behavior**: Understanding why code behaves unexpectedly
|
||||
- **Production Issues**: Investigating issues in live environments
|
||||
- **Test Failures**: Understanding why tests are failing
|
||||
- **Integration Problems**: Diagnosing issues between components
|
||||
**ALWAYS** - Apply this workflow to every bug investigation, regardless of
|
||||
severity or complexity. This ensures systematic problem-solving and prevents
|
||||
common investigation pitfalls.
|
||||
|
||||
## Bundled Rules
|
||||
|
||||
### **Investigation Process**
|
||||
### **Investigation Foundation**
|
||||
|
||||
- **`development/research_diagnostic.mdc`** - Systematic investigation
|
||||
workflow with evidence collection and analysis
|
||||
- **`development/investigation_report_example.mdc`** - Investigation
|
||||
documentation templates and examples
|
||||
- **`core/harbor_pilot_universal.mdc`** - Technical guide creation
|
||||
for complex investigations
|
||||
- **`development/research_diagnostic.mdc`** - Research and investigation methodologies
|
||||
- **`development/logging_standards.mdc`** - Logging and debugging best practices
|
||||
- **`development/type_safety_guide.mdc`** - Type safety and error prevention
|
||||
|
||||
### **Evidence Collection**
|
||||
### **Development Workflow**
|
||||
|
||||
- **`development/logging_standards.mdc`** - Logging implementation
|
||||
standards for debugging and evidence collection
|
||||
- **`development/time.mdc`** - Timestamp requirements and time
|
||||
handling standards for evidence
|
||||
- **`development/time_examples.mdc`** - Practical examples of
|
||||
proper time handling in investigations
|
||||
- **`workflow/version_control.mdc`** - Version control during investigation
|
||||
- **`development/software_development.mdc`** - Development best practices
|
||||
|
||||
### **Technical Context**
|
||||
## Critical Development Constraints
|
||||
|
||||
- **`app/timesafari.mdc`** - Core application context and
|
||||
architecture for understanding the system
|
||||
- **`app/timesafari_platforms.mdc`** - Platform-specific
|
||||
considerations and constraints
|
||||
### **🚫 NEVER Use Build Commands During Diagnosis**
|
||||
|
||||
## Workflow Sequence
|
||||
**Critical Rule**: Never use `npm run build:web` or similar build commands during bug diagnosis
|
||||
|
||||
### **Phase 1: Initial Investigation (Start Here)**
|
||||
- **Reason**: These commands block the chat and prevent effective troubleshooting
|
||||
- **Impact**: Blocks user interaction, prevents real-time problem solving
|
||||
- **Alternative**: Use safe, fast commands for investigation
|
||||
- **When to use build**: Only after diagnosis is complete and fixes are ready for testing
|
||||
|
||||
1. **Research Diagnostic** - Use `research_diagnostic.mdc` for
|
||||
systematic investigation approach
|
||||
2. **Evidence Collection** - Apply `logging_standards.mdc` and
|
||||
`time.mdc` for proper evidence gathering
|
||||
3. **Context Understanding** - Review `timesafari.mdc` for
|
||||
application context
|
||||
### **Safe Diagnosis Commands**
|
||||
|
||||
### **Phase 2: Deep Investigation**
|
||||
✅ **Safe to use during diagnosis:**
|
||||
- `npm run lint-fix` - Syntax and style checking
|
||||
- `npm run type-check` - TypeScript validation (if available)
|
||||
- `git status` - Version control status
|
||||
- `ls` / `dir` - File listing
|
||||
- `cat` / `read_file` - File content inspection
|
||||
- `grep_search` - Text pattern searching
|
||||
|
||||
1. **Platform Analysis** - Check `timesafari_platforms.mdc` for
|
||||
platform-specific issues
|
||||
2. **Technical Guide Creation** - Use `harbor_pilot_universal.mdc`
|
||||
for complex investigation documentation
|
||||
3. **Evidence Analysis** - Apply `time_examples.mdc` for proper
|
||||
timestamp handling
|
||||
❌ **Never use during diagnosis:**
|
||||
- `npm run build:web` - Blocks chat
|
||||
- `npm run build:electron` - Blocks chat
|
||||
- `npm run build:capacitor` - Blocks chat
|
||||
- Any long-running build processes
|
||||
|
||||
### **Phase 3: Documentation & Reporting**
|
||||
## Investigation Workflow
|
||||
|
||||
1. **Investigation Report** - Use `investigation_report_example.mdc`
|
||||
for comprehensive documentation
|
||||
2. **Root Cause Analysis** - Synthesize findings into actionable
|
||||
insights
|
||||
### **Phase 1: Problem Definition**
|
||||
|
||||
## Success Criteria
|
||||
1. **Gather Evidence**
|
||||
- Error messages and stack traces
|
||||
- User-reported symptoms
|
||||
- System logs and timestamps
|
||||
- Reproduction steps
|
||||
|
||||
- [ ] **Root cause identified** with supporting evidence
|
||||
- [ ] **Evidence properly collected** with timestamps and context
|
||||
- [ ] **Investigation documented** using appropriate templates
|
||||
- [ ] **Platform factors considered** in diagnosis
|
||||
- [ ] **Reproduction steps documented** for verification
|
||||
- [ ] **Impact assessment completed** with scope defined
|
||||
- [ ] **Next steps identified** for resolution
|
||||
2. **Context Analysis**
|
||||
- When did the problem start?
|
||||
- What changed recently?
|
||||
- Which platform/environment?
|
||||
- User actions leading to the issue
|
||||
|
||||
### **Phase 2: Systematic Investigation**
|
||||
|
||||
1. **Code Inspection**
|
||||
- Relevant file examination
|
||||
- Import and dependency analysis
|
||||
- Syntax and type checking
|
||||
- Logic flow analysis
|
||||
|
||||
2. **Environment Analysis**
|
||||
- Platform-specific considerations
|
||||
- Configuration and settings
|
||||
- Database and storage state
|
||||
- Network and API connectivity
|
||||
|
||||
### **Phase 3: Root Cause Identification**
|
||||
|
||||
1. **Pattern Recognition**
|
||||
- Similar issues in codebase
|
||||
- Common failure modes
|
||||
- Platform-specific behaviors
|
||||
- Recent changes impact
|
||||
|
||||
2. **Hypothesis Testing**
|
||||
- Targeted code changes
|
||||
- Configuration modifications
|
||||
- Environment adjustments
|
||||
- Systematic elimination
|
||||
|
||||
## Investigation Techniques
|
||||
|
||||
### **Safe Code Analysis**
|
||||
|
||||
- **File Reading**: Use `read_file` tool for targeted inspection
|
||||
- **Pattern Searching**: Use `grep_search` for code patterns
|
||||
- **Semantic Search**: Use `codebase_search` for related functionality
|
||||
- **Import Tracing**: Follow dependency chains systematically
|
||||
|
||||
### **Error Analysis**
|
||||
|
||||
- **Stack Trace Analysis**: Identify error origin and propagation
|
||||
- **Log Correlation**: Match errors with system events
|
||||
- **Timeline Reconstruction**: Build sequence of events
|
||||
- **Context Preservation**: Maintain investigation state
|
||||
|
||||
### **Platform Considerations**
|
||||
|
||||
- **Web Platform**: Browser-specific behaviors and limitations
|
||||
- **Electron Platform**: Desktop app considerations
|
||||
- **Capacitor Platform**: Mobile app behaviors
|
||||
- **Cross-Platform**: Shared vs. platform-specific code
|
||||
|
||||
## Evidence Collection Standards
|
||||
|
||||
### **Timestamps**
|
||||
|
||||
- **UTC Format**: All timestamps in UTC for consistency
|
||||
- **Precision**: Include milliseconds for precise correlation
|
||||
- **Context**: Include relevant system state information
|
||||
- **Correlation**: Link events across different components
|
||||
|
||||
### **Error Context**
|
||||
|
||||
- **Full Error Objects**: Capture complete error information
|
||||
- **Stack Traces**: Preserve call stack for analysis
|
||||
- **User Actions**: Document steps leading to error
|
||||
- **System State**: Capture relevant configuration and state
|
||||
|
||||
### **Reproduction Steps**
|
||||
|
||||
- **Clear Sequence**: Step-by-step reproduction instructions
|
||||
- **Environment Details**: Platform, version, configuration
|
||||
- **Data Requirements**: Required data or state
|
||||
- **Expected vs. Actual**: Clear behavior comparison
|
||||
|
||||
## Investigation Documentation
|
||||
|
||||
### **Problem Summary**
|
||||
|
||||
- **Issue Description**: Clear, concise problem statement
|
||||
- **Impact Assessment**: Severity and user impact
|
||||
- **Scope Definition**: Affected components and users
|
||||
- **Priority Level**: Based on impact and frequency
|
||||
|
||||
### **Investigation Log**
|
||||
|
||||
- **Timeline**: Chronological investigation steps
|
||||
- **Evidence**: Collected information and findings
|
||||
- **Hypotheses**: Tested theories and results
|
||||
- **Conclusions**: Root cause identification
|
||||
|
||||
### **Solution Requirements**
|
||||
|
||||
- **Fix Description**: Required changes and approach
|
||||
- **Testing Strategy**: Validation and verification steps
|
||||
- **Rollback Plan**: Reversion strategy if needed
|
||||
- **Prevention Measures**: Future issue prevention
|
||||
|
||||
## Quality Standards
|
||||
|
||||
### **Investigation Completeness**
|
||||
|
||||
- **Evidence Sufficiency**: Adequate information for root cause
|
||||
- **Alternative Theories**: Considered and eliminated
|
||||
- **Platform Coverage**: All relevant platforms investigated
|
||||
- **Edge Cases**: Unusual scenarios considered
|
||||
|
||||
### **Documentation Quality**
|
||||
|
||||
- **Clear Communication**: Understandable to all stakeholders
|
||||
- **Technical Accuracy**: Precise technical details
|
||||
- **Actionable Insights**: Clear next steps and recommendations
|
||||
- **Knowledge Transfer**: Lessons learned for future reference
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Don't skip evidence collection** - leads to speculation
|
||||
- **Don't ignore platform differences** - misses platform-specific issues
|
||||
- **Don't skip documentation** - loses investigation insights
|
||||
- **Don't assume root cause** - verify with evidence
|
||||
- **Don't ignore time context** - misses temporal factors
|
||||
- **Don't skip reproduction steps** - makes verification impossible
|
||||
### **Investigation Mistakes**
|
||||
|
||||
## Integration Points
|
||||
- **Jumping to Solutions**: Implementing fixes before understanding
|
||||
- **Insufficient Evidence**: Making assumptions without data
|
||||
- **Platform Blindness**: Ignoring platform-specific behaviors
|
||||
- **Scope Creep**: Expanding investigation beyond original problem
|
||||
|
||||
### **With Other Meta-Rules**
|
||||
### **Communication Issues**
|
||||
|
||||
- **Feature Planning**: Use complexity assessment for investigation planning
|
||||
- **Bug Fixing**: Investigation results feed directly into fix implementation
|
||||
- **Feature Implementation**: Investigation insights inform future development
|
||||
- **Technical Jargon**: Using unclear terminology
|
||||
- **Missing Context**: Insufficient background information
|
||||
- **Unclear Recommendations**: Vague or ambiguous next steps
|
||||
- **Poor Documentation**: Incomplete or unclear investigation records
|
||||
|
||||
### **With Development Workflow**
|
||||
## Success Criteria
|
||||
|
||||
- Investigation findings inform testing strategy
|
||||
- Root cause analysis drives preventive measures
|
||||
- Evidence collection improves logging standards
|
||||
- [ ] **Problem clearly defined** with sufficient evidence
|
||||
- [ ] **Root cause identified** through systematic investigation
|
||||
- [ ] **Solution approach determined** with clear requirements
|
||||
- [ ] **Documentation complete** for knowledge transfer
|
||||
- [ ] **No chat-blocking commands** used during investigation
|
||||
- [ ] **Platform considerations** properly addressed
|
||||
- [ ] **Timeline and context** properly documented
|
||||
|
||||
## Feedback & Improvement
|
||||
## Integration with Other Meta-Rules
|
||||
|
||||
### **Sub-Rule Ratings (1-5 scale)**
|
||||
### **Bug Fixing**
|
||||
|
||||
- **Research Diagnostic**: ___/5 - Comments: _______________
|
||||
- **Investigation Report**: ___/5 - Comments: _______________
|
||||
- **Technical Guide Creation**: ___/5 - Comments: _______________
|
||||
- **Logging Standards**: ___/5 - Comments: _______________
|
||||
- **Time Standards**: ___/5 - Comments: _______________
|
||||
- **Investigation Results**: Provide foundation for fix implementation
|
||||
- **Solution Requirements**: Define what needs to be built
|
||||
- **Testing Strategy**: Inform validation approach
|
||||
- **Documentation**: Support implementation guidance
|
||||
|
||||
### **Workflow Feedback**
|
||||
### **Feature Planning**
|
||||
|
||||
- **Investigation Effectiveness**: How well did the process help find root cause?
|
||||
- **Missing Steps**: What investigation steps should be added?
|
||||
- **Process Gaps**: Where did the workflow break down?
|
||||
- **Root Cause Analysis**: Identify systemic issues
|
||||
- **Prevention Measures**: Plan future issue avoidance
|
||||
- **Architecture Improvements**: Identify structural enhancements
|
||||
- **Process Refinements**: Improve development workflows
|
||||
|
||||
### **Sub-Rule Improvements**
|
||||
### **Research and Documentation**
|
||||
|
||||
- **Clarity Issues**: Which rules were unclear or confusing?
|
||||
- **Missing Examples**: What examples would make rules more useful?
|
||||
- **Template Improvements**: How could investigation templates be better?
|
||||
|
||||
### **Overall Experience**
|
||||
|
||||
- **Time Saved**: How much time did this meta-rule save you?
|
||||
- **Quality Improvement**: Did following these rules improve your investigation?
|
||||
- **Recommendation**: Would you recommend this meta-rule to others?
|
||||
|
||||
## Model Implementation Checklist
|
||||
|
||||
### Before Bug Investigation
|
||||
|
||||
- [ ] **Problem Definition**: Clearly define what needs to be investigated
|
||||
- [ ] **Scope Definition**: Determine investigation scope and boundaries
|
||||
- [ ] **Evidence Planning**: Plan evidence collection strategy
|
||||
- [ ] **Stakeholder Identification**: Identify who needs to be involved
|
||||
|
||||
### During Bug Investigation
|
||||
|
||||
- [ ] **Rule Application**: Apply bundled rules in recommended sequence
|
||||
- [ ] **Evidence Collection**: Collect evidence systematically with timestamps
|
||||
- [ ] **Documentation**: Document investigation process and findings
|
||||
- [ ] **Validation**: Verify findings with reproduction steps
|
||||
|
||||
### After Bug Investigation
|
||||
|
||||
- [ ] **Report Creation**: Create comprehensive investigation report
|
||||
- [ ] **Root Cause Analysis**: Document root cause with evidence
|
||||
- [ ] **Feedback Collection**: Collect feedback on meta-rule effectiveness
|
||||
- [ ] **Process Improvement**: Identify improvements for future investigations
|
||||
- **Knowledge Base**: Contribute to troubleshooting guides
|
||||
- **Pattern Recognition**: Identify common failure modes
|
||||
- **Best Practices**: Develop investigation methodologies
|
||||
- **Team Training**: Improve investigation capabilities
|
||||
|
||||
---
|
||||
|
||||
**See also**:
|
||||
|
||||
- `.cursor/rules/meta_feature_planning.mdc` for planning investigation work
|
||||
- `.cursor/rules/meta_bug_fixing.mdc` for implementing fixes
|
||||
- `.cursor/rules/meta_feature_implementation.mdc` for preventive measures
|
||||
- `.cursor/rules/meta_feature_planning.mdc` for planning improvements
|
||||
- `.cursor/rules/meta_documentation.mdc` for documentation standards
|
||||
|
||||
**Status**: Active meta-rule for bug diagnosis
|
||||
**Priority**: High
|
||||
|
||||
@@ -10,6 +10,45 @@ This meta-rule bundles all the rules needed for implementing bug fixes
|
||||
with proper testing and validation. Use this after diagnosis when
|
||||
implementing the actual fix.
|
||||
|
||||
## Workflow Constraints
|
||||
|
||||
**This meta-rule enforces FIXING MODE for all bundled sub-rules:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowMode": "fixing",
|
||||
"constraints": {
|
||||
"mode": "implementation",
|
||||
"allowed": ["modify", "create", "build", "test", "commit"],
|
||||
"required": "diagnosis_complete_before_fixing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**All bundled sub-rules automatically inherit these constraints.**
|
||||
|
||||
## Workflow State Update
|
||||
|
||||
**When this meta-rule is invoked, update the workflow state file:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentMode": "fixing",
|
||||
"lastInvoked": "meta_bug_fixing.mdc",
|
||||
"timestamp": "2025-01-27T15:30:00Z",
|
||||
"constraints": {
|
||||
"mode": "implementation",
|
||||
"allowed": ["modify", "create", "build", "test", "commit"],
|
||||
"forbidden": [],
|
||||
"required": "diagnosis_complete_before_fixing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**State File Location**: `.cursor/rules/.workflow_state.json`
|
||||
|
||||
**This enables the core always-on rule to enforce fixing mode constraints.**
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Post-Diagnosis**: After root cause is identified and fix is planned
|
||||
|
||||
383
.cursor/rules/meta_change_evaluation.mdc
Normal file
383
.cursor/rules/meta_change_evaluation.mdc
Normal file
@@ -0,0 +1,383 @@
|
||||
# Meta-Rule: Change Evaluation and Breaking Change Detection
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-08-25
|
||||
**Status**: 🎯 **ACTIVE** - Manually activated change evaluation rule
|
||||
|
||||
## Purpose
|
||||
|
||||
This meta-rule provides a systematic approach to evaluate changes between
|
||||
branches and detect potential breaking changes. It's designed to catch
|
||||
problematic model behavior by analyzing the nature, scope, and impact of
|
||||
code changes before they cause issues.
|
||||
|
||||
## When to Use
|
||||
|
||||
**Manual Activation Only** - This rule should be invoked when:
|
||||
|
||||
- Reviewing changes before merging branches
|
||||
- Investigating unexpected behavior after updates
|
||||
- Validating that model-generated changes are safe
|
||||
- Analyzing the impact of recent commits
|
||||
- Debugging issues that may be caused by recent changes
|
||||
|
||||
## Workflow State Enforcement
|
||||
|
||||
**This meta-rule enforces current workflow mode constraints:**
|
||||
|
||||
### **Current Workflow State**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowState": {
|
||||
"currentMode": "diagnosis|fixing|planning|research|documentation",
|
||||
"constraints": {
|
||||
"mode": "read_only|implementation|design_only|investigation|writing_only",
|
||||
"allowed": ["array", "of", "allowed", "actions"],
|
||||
"forbidden": ["array", "of", "forbidden", "actions"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Mode-Specific Enforcement**
|
||||
|
||||
**Diagnosis Mode (read_only):**
|
||||
|
||||
- ❌ **Forbidden**: File modification, code creation, build commands, git
|
||||
commits
|
||||
- ✅ **Allowed**: File reading, code analysis, investigation, documentation
|
||||
- **Response**: Focus on analysis and documentation, not implementation
|
||||
|
||||
**Fixing Mode (implementation):**
|
||||
|
||||
- ✅ **Allowed**: File modification, code creation, build commands, testing,
|
||||
git commits
|
||||
- ❌ **Forbidden**: None (full implementation mode)
|
||||
- **Response**: Proceed with implementation and testing
|
||||
|
||||
**Planning Mode (design_only):**
|
||||
|
||||
- ❌ **Forbidden**: Implementation, coding, building, deployment
|
||||
- ✅ **Allowed**: Analysis, design, estimation, documentation, architecture
|
||||
- **Response**: Focus on planning and design, not implementation
|
||||
|
||||
**Research Mode (investigation):**
|
||||
|
||||
- ❌ **Forbidden**: File modification, implementation, deployment
|
||||
- ✅ **Allowed**: Investigation, analysis, research, documentation
|
||||
- **Response**: Focus on investigation and analysis
|
||||
|
||||
**Documentation Mode (writing_only):**
|
||||
|
||||
- ❌ **Forbidden**: Implementation, coding, building, deployment
|
||||
- ✅ **Allowed**: Writing, editing, formatting, structuring, reviewing
|
||||
- **Response**: Focus on documentation creation and improvement
|
||||
|
||||
## Change Evaluation Process
|
||||
|
||||
### **Phase 1: Change Discovery and Analysis**
|
||||
|
||||
1. **Branch Comparison Analysis**
|
||||
|
||||
- Compare working branch with master/main branch
|
||||
- Identify all changed files and their modification types
|
||||
- Categorize changes by scope and impact
|
||||
|
||||
2. **Change Pattern Recognition**
|
||||
|
||||
- Identify common change patterns (refactoring, feature addition, bug
|
||||
fixes)
|
||||
- Detect unusual or suspicious change patterns
|
||||
- Flag changes that deviate from established patterns
|
||||
|
||||
3. **Dependency Impact Assessment**
|
||||
|
||||
- Analyze changes to imports, exports, and interfaces
|
||||
- Identify potential breaking changes to public APIs
|
||||
- Assess impact on dependent components and services
|
||||
|
||||
### **Phase 2: Breaking Change Detection**
|
||||
|
||||
1. **API Contract Analysis**
|
||||
|
||||
- Check for changes to function signatures, method names, class
|
||||
interfaces
|
||||
- Identify removed or renamed public methods/properties
|
||||
- Detect changes to configuration options and constants
|
||||
|
||||
2. **Data Structure Changes**
|
||||
|
||||
- Analyze database schema modifications
|
||||
- Check for changes to data models and interfaces
|
||||
- Identify modifications to serialization/deserialization logic
|
||||
|
||||
3. **Behavioral Changes**
|
||||
|
||||
- Detect changes to business logic and algorithms
|
||||
- Identify modifications to error handling and validation
|
||||
- Check for changes to user experience and workflows
|
||||
|
||||
### **Phase 3: Risk Assessment and Recommendations**
|
||||
|
||||
1. **Risk Level Classification**
|
||||
|
||||
- **LOW**: Cosmetic changes, documentation updates, minor refactoring
|
||||
- **MEDIUM**: Internal API changes, configuration modifications,
|
||||
performance improvements
|
||||
- **HIGH**: Public API changes, breaking interface modifications, major
|
||||
architectural changes
|
||||
- **CRITICAL**: Database schema changes, authentication modifications,
|
||||
security-related changes
|
||||
|
||||
2. **Impact Analysis**
|
||||
|
||||
- Identify affected user groups and use cases
|
||||
- Assess potential for data loss or corruption
|
||||
- Evaluate impact on system performance and reliability
|
||||
|
||||
3. **Mitigation Strategies**
|
||||
|
||||
- Recommend testing approaches for affected areas
|
||||
- Suggest rollback strategies if needed
|
||||
- Identify areas requiring additional validation
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### **Change Analysis Tools**
|
||||
|
||||
1. **Git Diff Analysis**
|
||||
|
||||
```bash
|
||||
# Compare working branch with master
|
||||
git diff master..HEAD --name-only
|
||||
git diff master..HEAD --stat
|
||||
git log master..HEAD --oneline
|
||||
```
|
||||
|
||||
2. **File Change Categorization**
|
||||
|
||||
- **Core Files**: Application entry points, main services, critical
|
||||
utilities
|
||||
- **Interface Files**: Public APIs, component interfaces, data models
|
||||
- **Configuration Files**: Environment settings, build configurations,
|
||||
deployment scripts
|
||||
- **Test Files**: Unit tests, integration tests, test utilities
|
||||
|
||||
3. **Change Impact Mapping**
|
||||
|
||||
- Map changed files to affected functionality
|
||||
- Identify cross-dependencies and ripple effects
|
||||
- Document potential side effects and unintended consequences
|
||||
|
||||
### **Breaking Change Detection Patterns**
|
||||
|
||||
1. **Function Signature Changes**
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
function processData(data: string, options?: Options): Result
|
||||
|
||||
// AFTER - BREAKING CHANGE
|
||||
function processData(data: string, options: Required<Options>): Result
|
||||
```
|
||||
|
||||
2. **Interface Modifications**
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// AFTER - BREAKING CHANGE
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string; // Required new field
|
||||
}
|
||||
```
|
||||
|
||||
3. **Configuration Changes**
|
||||
|
||||
```typescript
|
||||
// BEFORE
|
||||
const config = {
|
||||
apiUrl: 'https://api.example.com',
|
||||
timeout: 5000
|
||||
};
|
||||
|
||||
// AFTER - BREAKING CHANGE
|
||||
const config = {
|
||||
apiUrl: 'https://api.example.com',
|
||||
timeout: 5000,
|
||||
retries: 3 // New required configuration
|
||||
};
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
### **Change Evaluation Report**
|
||||
|
||||
```markdown
|
||||
# Change Evaluation Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
- **Risk Level**: [LOW|MEDIUM|HIGH|CRITICAL]
|
||||
- **Overall Assessment**: [SAFE|CAUTION|DANGEROUS|CRITICAL]
|
||||
- **Recommendation**: [PROCEED|REVIEW|HALT|IMMEDIATE_ROLLBACK]
|
||||
|
||||
## Change Analysis
|
||||
|
||||
### Files Modified
|
||||
|
||||
- **Total Changes**: [X] files
|
||||
- **Core Files**: [X] files
|
||||
- **Interface Files**: [X] files
|
||||
- **Configuration Files**: [X] files
|
||||
- **Test Files**: [X] files
|
||||
|
||||
### Change Categories
|
||||
|
||||
- **Refactoring**: [X] changes
|
||||
- **Feature Addition**: [X] changes
|
||||
- **Bug Fixes**: [X] changes
|
||||
- **Configuration**: [X] changes
|
||||
- **Documentation**: [X] changes
|
||||
|
||||
## Breaking Change Detection
|
||||
|
||||
### API Contract Changes
|
||||
|
||||
- **Function Signatures**: [X] modified
|
||||
- **Interface Definitions**: [X] modified
|
||||
- **Public Methods**: [X] added/removed/modified
|
||||
|
||||
### Data Structure Changes
|
||||
|
||||
- **Database Schema**: [X] modifications
|
||||
- **Data Models**: [X] changes
|
||||
- **Serialization**: [X] changes
|
||||
|
||||
### Behavioral Changes
|
||||
|
||||
- **Business Logic**: [X] modifications
|
||||
- **Error Handling**: [X] changes
|
||||
- **User Experience**: [X] changes
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Impact Analysis
|
||||
|
||||
- **User Groups Affected**: [Description]
|
||||
- **Use Cases Impacted**: [Description]
|
||||
- **Performance Impact**: [Description]
|
||||
- **Reliability Impact**: [Description]
|
||||
|
||||
### Dependencies
|
||||
|
||||
- **Internal Dependencies**: [List]
|
||||
- **External Dependencies**: [List]
|
||||
- **Configuration Dependencies**: [List]
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- [ ] Unit tests for modified components
|
||||
- [ ] Integration tests for affected workflows
|
||||
- [ ] Performance tests for changed algorithms
|
||||
- [ ] User acceptance tests for UI changes
|
||||
|
||||
### Validation Steps
|
||||
|
||||
- [ ] Code review by domain experts
|
||||
- [ ] API compatibility testing
|
||||
- [ ] Database migration testing
|
||||
- [ ] End-to-end workflow testing
|
||||
|
||||
### Rollback Strategy
|
||||
|
||||
- **Rollback Complexity**: [LOW|MEDIUM|HIGH]
|
||||
- **Rollback Time**: [Estimated time]
|
||||
- **Data Preservation**: [Strategy description]
|
||||
|
||||
## Conclusion
|
||||
|
||||
[Summary of findings and final recommendation]
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### **Example 1: Safe Refactoring**
|
||||
|
||||
```bash
|
||||
@meta_change_evaluation.mdc analyze changes between feature-branch and master
|
||||
```
|
||||
|
||||
### **Example 2: Breaking Change Investigation**
|
||||
|
||||
```bash
|
||||
@meta_change_evaluation.mdc evaluate potential breaking changes in recent commits
|
||||
```
|
||||
|
||||
### **Example 3: Pre-Merge Validation**
|
||||
|
||||
```bash
|
||||
@meta_change_evaluation.mdc validate changes before merging feature-branch to master
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] **Change Discovery**: All modified files are identified and categorized
|
||||
- [ ] **Pattern Recognition**: Unusual change patterns are detected and flagged
|
||||
- [ ] **Breaking Change Detection**: All potential breaking changes are identified
|
||||
- [ ] **Risk Assessment**: Accurate risk levels are assigned with justification
|
||||
- [ ] **Recommendations**: Actionable recommendations are provided
|
||||
- [ ] **Documentation**: Complete change evaluation report is generated
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Missing Dependencies**: Failing to identify all affected components
|
||||
- **Underestimating Impact**: Not considering ripple effects of changes
|
||||
- **Incomplete Testing**: Missing critical test scenarios for changes
|
||||
- **Configuration Blindness**: Overlooking configuration file changes
|
||||
- **Interface Assumptions**: Assuming internal changes won't affect external
|
||||
users
|
||||
|
||||
## Integration with Other Meta-Rules
|
||||
|
||||
### **With Bug Diagnosis**
|
||||
|
||||
- Use change evaluation to identify recent changes that may have caused
|
||||
bugs
|
||||
- Correlate change patterns with reported issues
|
||||
|
||||
### **With Feature Planning**
|
||||
|
||||
- Evaluate the impact of planned changes before implementation
|
||||
- Identify potential breaking changes early in the planning process
|
||||
|
||||
### **With Bug Fixing**
|
||||
|
||||
- Validate that fixes don't introduce new breaking changes
|
||||
- Ensure fixes maintain backward compatibility
|
||||
|
||||
---
|
||||
|
||||
**See also**:
|
||||
|
||||
- `.cursor/rules/meta_core_always_on.mdc` for core always-on rules
|
||||
- `.cursor/rules/meta_feature_planning.mdc` for feature development
|
||||
workflows
|
||||
- `.cursor/rules/meta_bug_diagnosis.mdc` for bug investigation workflows
|
||||
- `.cursor/rules/meta_bug_fixing.mdc` for fix implementation workflows
|
||||
|
||||
**Status**: Active change evaluation meta-rule
|
||||
**Priority**: High (applies to all change evaluation tasks)
|
||||
**Estimated Effort**: Ongoing reference
|
||||
**Dependencies**: All bundled sub-rules
|
||||
**Stakeholders**: Development team, Quality Assurance team, Release
|
||||
Management team
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Meta-Rule: Core Always-On Rules
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
@@ -14,6 +13,109 @@ This meta-rule bundles the core rules that should be applied to **every single
|
||||
prompt** because they define fundamental behaviors, principles, and context
|
||||
that are essential for all AI interactions.
|
||||
|
||||
## Workflow Constraints
|
||||
|
||||
**This meta-rule enforces ALWAYS-ON MODE for all bundled sub-rules:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowMode": "always_on",
|
||||
"constraints": {
|
||||
"mode": "foundation",
|
||||
"alwaysApplied": true,
|
||||
"required": "applied_to_every_prompt"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**All bundled sub-rules automatically inherit these constraints.**
|
||||
|
||||
## Workflow State Enforcement
|
||||
|
||||
**This meta-rule enforces current workflow mode constraints for all interactions:**
|
||||
|
||||
### **Current Workflow State**
|
||||
```json
|
||||
{
|
||||
"workflowState": {
|
||||
"currentMode": "diagnosis|fixing|planning|research|documentation",
|
||||
"constraints": {
|
||||
"mode": "read_only|implementation|design_only|investigation|writing_only",
|
||||
"allowed": ["array", "of", "allowed", "actions"],
|
||||
"forbidden": ["array", "of", "forbidden", "actions"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Constraint Enforcement Rules**
|
||||
|
||||
**Before responding to any user request, enforce current mode constraints:**
|
||||
|
||||
1. **Read current workflow state** from `.cursor/rules/.workflow_state.json`
|
||||
2. **Identify current mode** and its constraints
|
||||
3. **Validate user request** against current mode constraints
|
||||
4. **Enforce constraints** before generating response
|
||||
5. **Guide model behavior** based on current mode
|
||||
|
||||
### **Mode-Specific Enforcement**
|
||||
|
||||
**Diagnosis Mode (read_only):**
|
||||
- ❌ **Forbidden**: File modification, code creation, build commands, git commits
|
||||
- ✅ **Allowed**: File reading, code analysis, investigation, documentation
|
||||
- **Response**: Guide user toward investigation and analysis, not implementation
|
||||
|
||||
**Fixing Mode (implementation):**
|
||||
- ✅ **Allowed**: File modification, code creation, build commands, testing, git commits
|
||||
- ❌ **Forbidden**: None (full implementation mode)
|
||||
- **Response**: Proceed with implementation and testing
|
||||
|
||||
**Planning Mode (design_only):**
|
||||
- ❌ **Forbidden**: Implementation, coding, building, deployment
|
||||
- ✅ **Allowed**: Analysis, design, estimation, documentation, architecture
|
||||
- **Response**: Focus on planning and design, not implementation
|
||||
|
||||
**Research Mode (investigation):**
|
||||
- ❌ **Forbidden**: File modification, implementation, deployment
|
||||
- ✅ **Allowed**: Investigation, analysis, research, documentation
|
||||
- **Response**: Focus on investigation and analysis
|
||||
|
||||
**Documentation Mode (writing_only):**
|
||||
- ❌ **Forbidden**: Implementation, coding, building, deployment
|
||||
- ✅ **Allowed**: Writing, editing, formatting, structuring, reviewing
|
||||
- **Response**: Focus on documentation creation and improvement
|
||||
|
||||
### **Constraint Violation Response**
|
||||
|
||||
**If user request violates current mode constraints:**
|
||||
|
||||
```
|
||||
❌ **WORKFLOW CONSTRAINT VIOLATION**
|
||||
|
||||
**Current Mode**: [MODE_NAME]
|
||||
**Requested Action**: [ACTION]
|
||||
**Constraint Violation**: [DESCRIPTION]
|
||||
|
||||
**What You Can Do Instead**:
|
||||
- [LIST OF ALLOWED ALTERNATIVES]
|
||||
|
||||
**To Enable This Action**: Invoke @meta_[appropriate_mode].mdc
|
||||
```
|
||||
|
||||
### **Mode Transition Guidance**
|
||||
|
||||
**When user needs to change modes, provide clear guidance:**
|
||||
|
||||
```
|
||||
🔄 **MODE TRANSITION REQUIRED**
|
||||
|
||||
**Current Mode**: [CURRENT_MODE]
|
||||
**Required Mode**: [REQUIRED_MODE]
|
||||
**Action**: Invoke @meta_[required_mode].mdc
|
||||
|
||||
**This will enable**: [DESCRIPTION OF NEW CAPABILITIES]
|
||||
```
|
||||
|
||||
## When to Use
|
||||
|
||||
**ALWAYS** - These rules apply to every single prompt, regardless of the task
|
||||
@@ -165,6 +267,8 @@ or context. They form the foundation for all AI assistant behavior.
|
||||
- [ ] **Time Standards**: Verify UTC and timestamp requirements are clear
|
||||
- [ ] **Application Context**: Confirm TimeSafari context is loaded
|
||||
- [ ] **Version Control**: Prepare commit standards if code changes are needed
|
||||
- [ ] **Workflow State**: Read current mode constraints from state file
|
||||
- [ ] **Constraint Validation**: Validate user request against current mode
|
||||
|
||||
### During Response Creation
|
||||
|
||||
@@ -172,6 +276,8 @@ or context. They form the foundation for all AI assistant behavior.
|
||||
- [ ] **Competence Hooks**: Include learning and collaboration elements
|
||||
- [ ] **Time Consistency**: Apply UTC standards for all time references
|
||||
- [ ] **Platform Awareness**: Consider all target platforms
|
||||
- [ ] **Mode Enforcement**: Apply current mode constraints to response
|
||||
- [ ] **Constraint Violations**: Block forbidden actions and guide alternatives
|
||||
|
||||
### After Response Creation
|
||||
|
||||
@@ -179,18 +285,23 @@ or context. They form the foundation for all AI assistant behavior.
|
||||
- [ ] **Quality Check**: Ensure response meets competence standards
|
||||
- [ ] **Context Review**: Confirm application context was properly considered
|
||||
- [ ] **Feedback Collection**: Note any issues with always-on application
|
||||
- [ ] **Mode Compliance**: Verify response stayed within current mode constraints
|
||||
- [ ] **Transition Guidance**: Provide clear guidance for mode changes if needed
|
||||
|
||||
---
|
||||
|
||||
**See also**:
|
||||
|
||||
- `.cursor/rules/meta_feature_planning.mdc` for workflow-specific rules
|
||||
- `.cursor/rules/meta_bug_diagnosis.mdc` for investigation workflows
|
||||
- `.cursor/rules/meta_bug_fixing.mdc` for fix implementation
|
||||
- `.cursor/rules/meta_feature_implementation.mdc` for feature development
|
||||
|
||||
**Status**: Active core always-on meta-rule
|
||||
**Priority**: Critical (applies to every prompt)
|
||||
**Estimated Effort**: Ongoing reference
|
||||
**Dependencies**: All bundled sub-rules
|
||||
**Stakeholders**: All AI interactions, Development team
|
||||
|
||||
**Dependencies**: All bundled sub-rules
|
||||
**Stakeholders**: All AI interactions, Development team
|
||||
|
||||
**Dependencies**: All bundled sub-rules
|
||||
**Stakeholders**: All AI interactions, Development team
|
||||
|
||||
@@ -10,6 +10,44 @@ This meta-rule bundles documentation-related rules to create comprehensive,
|
||||
educational documentation that increases human competence rather than just
|
||||
providing technical descriptions.
|
||||
|
||||
## Workflow Constraints
|
||||
|
||||
**This meta-rule enforces DOCUMENTATION MODE for all bundled sub-rules:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowMode": "documentation",
|
||||
"constraints": {
|
||||
"mode": "writing_only",
|
||||
"allowed": ["write", "edit", "format", "structure", "review"],
|
||||
"forbidden": ["implement", "code", "build", "deploy"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**All bundled sub-rules automatically inherit these constraints.**
|
||||
|
||||
## Workflow State Update
|
||||
|
||||
**When this meta-rule is invoked, update the workflow state file:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentMode": "documentation",
|
||||
"lastInvoked": "meta_documentation.mdc",
|
||||
"timestamp": "2025-01-27T15:30:00Z",
|
||||
"constraints": {
|
||||
"mode": "writing_only",
|
||||
"allowed": ["write", "edit", "format", "structure", "review"],
|
||||
"forbidden": ["implement", "code", "build", "deploy"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**State File Location**: `.cursor/rules/.workflow_state.json`
|
||||
|
||||
**This enables the core always-on rule to enforce documentation mode constraints.**
|
||||
|
||||
## When to Use
|
||||
|
||||
**Use this meta-rule when**:
|
||||
|
||||
@@ -10,6 +10,45 @@ This meta-rule bundles all the rules needed for building features with
|
||||
proper architecture and cross-platform support. Use this when implementing
|
||||
planned features or refactoring existing code.
|
||||
|
||||
## Workflow Constraints
|
||||
|
||||
**This meta-rule enforces IMPLEMENTATION MODE for all bundled sub-rules:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowMode": "implementation",
|
||||
"constraints": {
|
||||
"mode": "development",
|
||||
"allowed": ["code", "build", "test", "refactor", "deploy"],
|
||||
"required": "planning_complete_before_implementation"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**All bundled sub-rules automatically inherit these constraints.**
|
||||
|
||||
## Workflow State Update
|
||||
|
||||
**When this meta-rule is invoked, update the workflow state file:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentMode": "implementation",
|
||||
"lastInvoked": "meta_feature_implementation.mdc",
|
||||
"timestamp": "2025-01-27T15:30:00Z",
|
||||
"constraints": {
|
||||
"mode": "development",
|
||||
"allowed": ["code", "build", "test", "refactor", "deploy"],
|
||||
"forbidden": [],
|
||||
"required": "planning_complete_before_implementation"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**State File Location**: `.cursor/rules/.workflow_state.json`
|
||||
|
||||
**This enables the core always-on rule to enforce implementation mode constraints.**
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Feature Development**: Building new features from planning
|
||||
|
||||
@@ -10,6 +10,44 @@ This meta-rule bundles all the rules needed for comprehensive feature planning
|
||||
across all platforms. Use this when starting any new feature development,
|
||||
planning sprints, or estimating work effort.
|
||||
|
||||
## Workflow Constraints
|
||||
|
||||
**This meta-rule enforces PLANNING MODE for all bundled sub-rules:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowMode": "planning",
|
||||
"constraints": {
|
||||
"mode": "design_only",
|
||||
"allowed": ["analyze", "plan", "design", "estimate", "document"],
|
||||
"forbidden": ["implement", "code", "build", "test", "deploy"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**All bundled sub-rules automatically inherit these constraints.**
|
||||
|
||||
## Workflow State Update
|
||||
|
||||
**When this meta-rule is invoked, update the workflow state file:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentMode": "planning",
|
||||
"lastInvoked": "meta_feature_planning.mdc",
|
||||
"timestamp": "2025-01-27T15:30:00Z",
|
||||
"constraints": {
|
||||
"mode": "design_only",
|
||||
"allowed": ["analyze", "plan", "design", "estimate", "document"],
|
||||
"forbidden": ["implement", "code", "build", "test", "deploy"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**State File Location**: `.cursor/rules/.workflow_state.json`
|
||||
|
||||
**This enables the core always-on rule to enforce planning mode constraints.**
|
||||
|
||||
## When to Use
|
||||
|
||||
- **New Feature Development**: Planning features from concept to implementation
|
||||
|
||||
@@ -11,6 +11,44 @@ systematic investigation, analysis, evidence collection, or research tasks. It p
|
||||
a comprehensive framework for thorough, methodical research workflows that produce
|
||||
actionable insights and evidence-based conclusions.
|
||||
|
||||
## Workflow Constraints
|
||||
|
||||
**This meta-rule enforces RESEARCH MODE for all bundled sub-rules:**
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowMode": "research",
|
||||
"constraints": {
|
||||
"mode": "investigation",
|
||||
"allowed": ["read", "search", "analyze", "plan"],
|
||||
"forbidden": ["modify", "create", "build", "commit"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**All bundled sub-rules automatically inherit these constraints.**
|
||||
|
||||
## Workflow State Update
|
||||
|
||||
**When this meta-rule is invoked, update the workflow state file:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentMode": "research",
|
||||
"lastInvoked": "meta_research.mdc",
|
||||
"timestamp": "2025-01-27T15:30:00Z",
|
||||
"constraints": {
|
||||
"mode": "investigation",
|
||||
"allowed": ["read", "search", "analyze", "plan"],
|
||||
"forbidden": ["modify", "create", "build", "commit"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**State File Location**: `.cursor/rules/.workflow_state.json`
|
||||
|
||||
**This enables the core always-on rule to enforce research mode constraints.**
|
||||
|
||||
## When to Use
|
||||
|
||||
**RESEARCH TASKS** - Apply this meta-rule when:
|
||||
|
||||
356
.cursor/rules/playwright-test-investigation.mdc
Normal file
356
.cursor/rules/playwright-test-investigation.mdc
Normal file
@@ -0,0 +1,356 @@
|
||||
---
|
||||
description: when working with playwright tests either generating them or using them to test code
|
||||
alwaysApply: false
|
||||
---
|
||||
# Playwright Test Investigation — Harbor Pilot Directive
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-08-21T14:22Z
|
||||
**Status**: 🎯 **ACTIVE** - Playwright test debugging guidelines
|
||||
|
||||
## Objective
|
||||
Provide systematic approach for investigating Playwright test failures with focus on UI element conflicts, timing issues, and selector ambiguity.
|
||||
|
||||
## Context & Scope
|
||||
- **Audience**: Developers debugging Playwright test failures
|
||||
- **In scope**: Test failure analysis, selector conflicts, UI state investigation, timing issues
|
||||
- **Out of scope**: Test writing best practices, CI/CD configuration
|
||||
|
||||
## Artifacts & Links
|
||||
- Test results: `test-results/` directory
|
||||
- Error context: `error-context.md` files with page snapshots
|
||||
- Trace files: `trace.zip` files for failed tests
|
||||
- HTML reports: Interactive test reports with screenshots
|
||||
|
||||
## Environment & Preconditions
|
||||
- OS/Runtime: Linux/Windows/macOS with Node.js
|
||||
- Versions: Playwright test framework, browser drivers
|
||||
- Services: Local test server (localhost:8080), test data setup
|
||||
- Auth mode: None required for test investigation
|
||||
|
||||
## Architecture / Process Overview
|
||||
Playwright test investigation follows a systematic diagnostic workflow that leverages built-in debugging tools and error context analysis.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Test Failure] --> B[Check Error Context]
|
||||
B --> C[Analyze Page Snapshot]
|
||||
C --> D[Identify UI Conflicts]
|
||||
D --> E[Check Trace Files]
|
||||
E --> F[Verify Selector Uniqueness]
|
||||
F --> G[Test Selector Fixes]
|
||||
G --> H[Document Root Cause]
|
||||
|
||||
B --> I[Check Test Results Directory]
|
||||
I --> J[Locate Failed Test Results]
|
||||
J --> K[Extract Error Details]
|
||||
|
||||
D --> L[Multiple Alerts?]
|
||||
L --> M[Button Text Conflicts?]
|
||||
M --> N[Timing Issues?]
|
||||
|
||||
E --> O[Use Trace Viewer]
|
||||
O --> P[Analyze Action Sequence]
|
||||
P --> Q[Identify Failure Point]
|
||||
```
|
||||
|
||||
## Interfaces & Contracts
|
||||
|
||||
### Test Results Structure
|
||||
| Component | Format | Content | Validation |
|
||||
|---|---|---|---|
|
||||
| Error Context | Markdown | Page snapshot in YAML | Verify DOM state matches test expectations |
|
||||
| Trace Files | ZIP archive | Detailed execution trace | Use `npx playwright show-trace` |
|
||||
| HTML Reports | Interactive HTML | Screenshots, traces, logs | Check browser for full report |
|
||||
| JSON Results | JSON | Machine-readable results | Parse for automated analysis |
|
||||
|
||||
### Investigation Commands
|
||||
| Step | Command | Expected Output | Notes |
|
||||
|---|---|---|---|
|
||||
| Locate failed tests | `find test-results -name "*test-name*"` | Test result directories | Use exact test name patterns |
|
||||
| Check error context | `cat test-results/*/error-context.md` | Page snapshots | Look for UI state conflicts |
|
||||
| View traces | `npx playwright show-trace trace.zip` | Interactive trace viewer | Analyze exact failure sequence |
|
||||
|
||||
## Repro: End-to-End Investigation Procedure
|
||||
|
||||
### 1. Locate Failed Test Results
|
||||
```bash
|
||||
# Find all results for a specific test
|
||||
find test-results -name "*test-name*" -type d
|
||||
|
||||
# Check for error context files
|
||||
find test-results -name "error-context.md" | head -5
|
||||
```
|
||||
|
||||
### 2. Analyze Error Context
|
||||
```bash
|
||||
# Read error context for specific test
|
||||
cat test-results/test-name-test-description-browser/error-context.md
|
||||
|
||||
# Look for UI conflicts in page snapshot
|
||||
grep -A 10 -B 5 "button.*Yes\|button.*No" test-results/*/error-context.md
|
||||
```
|
||||
|
||||
### 3. Check Trace Files
|
||||
```bash
|
||||
# List available trace files
|
||||
find test-results -name "*.zip" | grep trace
|
||||
|
||||
# View trace in browser
|
||||
npx playwright show-trace test-results/test-name/trace.zip
|
||||
```
|
||||
|
||||
### 4. Investigate Selector Issues
|
||||
```typescript
|
||||
// Check for multiple elements with same text
|
||||
await page.locator('button:has-text("Yes")').count(); // Should be 1
|
||||
|
||||
// Use more specific selectors
|
||||
await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes")').click();
|
||||
```
|
||||
|
||||
## What Works (Evidence)
|
||||
- ✅ **Error context files** provide page snapshots showing exact DOM state at failure
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: `test-results/60-new-activity-New-offers-for-another-user-chromium/error-context.md` shows both alerts visible
|
||||
- **Verify at**: Error context files in test results directory
|
||||
|
||||
- ✅ **Trace files** capture detailed execution sequence for failed tests
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: `trace.zip` files available for all failed tests
|
||||
- **Verify at**: Use `npx playwright show-trace <filename>`
|
||||
|
||||
- ✅ **Page snapshots** reveal UI conflicts like multiple alerts with duplicate button text
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: YAML snapshots show registration + export alerts simultaneously
|
||||
- **Verify at**: Error context markdown files
|
||||
|
||||
## What Doesn't (Evidence & Hypotheses)
|
||||
- ❌ **Generic selectors** fail with multiple similar elements at `test-playwright/testUtils.ts:161`
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: `button:has-text("Yes")` matches both "Yes" and "Yes, Export Data"
|
||||
- **Hypothesis**: Selector ambiguity due to multiple alerts with conflicting button text
|
||||
- **Next probe**: Use more specific selectors or dismiss alerts sequentially
|
||||
|
||||
- ❌ **Timing-dependent tests** fail due to alert stacking at `src/views/ContactsView.vue:860,1283`
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: Both alerts use identical 1000ms delays, ensuring simultaneous display
|
||||
- **Hypothesis**: Race condition between alert displays creates UI conflicts
|
||||
- **Next probe**: Implement alert queuing or prevent overlapping alerts
|
||||
|
||||
## Risks, Limits, Assumptions
|
||||
- **Trace file size**: Large trace files may impact storage and analysis time
|
||||
- **Browser compatibility**: Trace viewer requires specific browser support
|
||||
- **Test isolation**: Shared state between tests may affect investigation results
|
||||
- **Timing sensitivity**: Tests may pass/fail based on system performance
|
||||
|
||||
## Next Steps
|
||||
| Owner | Task | Exit Criteria | Target Date (UTC) |
|
||||
|---|---|---|---|
|
||||
| Development Team | Fix test selectors for multiple alerts | All tests pass consistently | 2025-08-22 |
|
||||
| Development Team | Implement alert queuing system | No overlapping alerts with conflicting buttons | 2025-08-25 |
|
||||
| Development Team | Add test IDs to alert buttons | Unique selectors for all UI elements | 2025-08-28 |
|
||||
|
||||
## References
|
||||
- [Playwright Trace Viewer Documentation](https://playwright.dev/docs/trace-viewer)
|
||||
- [Playwright Test Results](https://playwright.dev/docs/test-reporters)
|
||||
- [Test Investigation Workflow](./research_diagnostic.mdc)
|
||||
|
||||
## Competence Hooks
|
||||
- **Why this works**: Systematic investigation leverages Playwright's built-in debugging tools to identify root causes
|
||||
- **Common pitfalls**: Generic selectors fail with multiple similar elements; timing issues create race conditions; alert stacking causes UI conflicts
|
||||
- **Next skill unlock**: Implement unique test IDs and handle alert dismissal order in test flows
|
||||
- **Teach-back**: "How would you investigate a Playwright test failure using error context, trace files, and page snapshots?"
|
||||
|
||||
## Collaboration Hooks
|
||||
- **Reviewers**: QA team, test automation engineers
|
||||
- **Sign-off checklist**: Error context analyzed, trace files reviewed, root cause identified, fix implemented and tested
|
||||
|
||||
## Assumptions & Limits
|
||||
- Test results directory structure follows Playwright conventions
|
||||
- Trace files are enabled in configuration (`trace: "retain-on-failure"`)
|
||||
- Error context files contain valid YAML page snapshots
|
||||
- Browser environment supports trace viewer functionality
|
||||
|
||||
---
|
||||
|
||||
**Status**: Active investigation directive
|
||||
**Priority**: High
|
||||
**Maintainer**: Development team
|
||||
**Next Review**: 2025-09-21
|
||||
# Playwright Test Investigation — Harbor Pilot Directive
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-08-21T14:22Z
|
||||
**Status**: 🎯 **ACTIVE** - Playwright test debugging guidelines
|
||||
|
||||
## Objective
|
||||
Provide systematic approach for investigating Playwright test failures with focus on UI element conflicts, timing issues, and selector ambiguity.
|
||||
|
||||
## Context & Scope
|
||||
- **Audience**: Developers debugging Playwright test failures
|
||||
- **In scope**: Test failure analysis, selector conflicts, UI state investigation, timing issues
|
||||
- **Out of scope**: Test writing best practices, CI/CD configuration
|
||||
|
||||
## Artifacts & Links
|
||||
- Test results: `test-results/` directory
|
||||
- Error context: `error-context.md` files with page snapshots
|
||||
- Trace files: `trace.zip` files for failed tests
|
||||
- HTML reports: Interactive test reports with screenshots
|
||||
|
||||
## Environment & Preconditions
|
||||
- OS/Runtime: Linux/Windows/macOS with Node.js
|
||||
- Versions: Playwright test framework, browser drivers
|
||||
- Services: Local test server (localhost:8080), test data setup
|
||||
- Auth mode: None required for test investigation
|
||||
|
||||
## Architecture / Process Overview
|
||||
Playwright test investigation follows a systematic diagnostic workflow that leverages built-in debugging tools and error context analysis.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Test Failure] --> B[Check Error Context]
|
||||
B --> C[Analyze Page Snapshot]
|
||||
C --> D[Identify UI Conflicts]
|
||||
D --> E[Check Trace Files]
|
||||
E --> F[Verify Selector Uniqueness]
|
||||
F --> G[Test Selector Fixes]
|
||||
G --> H[Document Root Cause]
|
||||
|
||||
B --> I[Check Test Results Directory]
|
||||
I --> J[Locate Failed Test Results]
|
||||
J --> K[Extract Error Details]
|
||||
|
||||
D --> L[Multiple Alerts?]
|
||||
L --> M[Button Text Conflicts?]
|
||||
M --> N[Timing Issues?]
|
||||
|
||||
E --> O[Use Trace Viewer]
|
||||
O --> P[Analyze Action Sequence]
|
||||
P --> Q[Identify Failure Point]
|
||||
```
|
||||
|
||||
## Interfaces & Contracts
|
||||
|
||||
### Test Results Structure
|
||||
| Component | Format | Content | Validation |
|
||||
|---|---|---|---|
|
||||
| Error Context | Markdown | Page snapshot in YAML | Verify DOM state matches test expectations |
|
||||
| Trace Files | ZIP archive | Detailed execution trace | Use `npx playwright show-trace` |
|
||||
| HTML Reports | Interactive HTML | Screenshots, traces, logs | Check browser for full report |
|
||||
| JSON Results | JSON | Machine-readable results | Parse for automated analysis |
|
||||
|
||||
### Investigation Commands
|
||||
| Step | Command | Expected Output | Notes |
|
||||
|---|---|---|---|
|
||||
| Locate failed tests | `find test-results -name "*test-name*"` | Test result directories | Use exact test name patterns |
|
||||
| Check error context | `cat test-results/*/error-context.md` | Page snapshots | Look for UI state conflicts |
|
||||
| View traces | `npx playwright show-trace trace.zip` | Interactive trace viewer | Analyze exact failure sequence |
|
||||
|
||||
## Repro: End-to-End Investigation Procedure
|
||||
|
||||
### 1. Locate Failed Test Results
|
||||
```bash
|
||||
# Find all results for a specific test
|
||||
find test-results -name "*test-name*" -type d
|
||||
|
||||
# Check for error context files
|
||||
find test-results -name "error-context.md" | head -5
|
||||
```
|
||||
|
||||
### 2. Analyze Error Context
|
||||
```bash
|
||||
# Read error context for specific test
|
||||
cat test-results/test-name-test-description-browser/error-context.md
|
||||
|
||||
# Look for UI conflicts in page snapshot
|
||||
grep -A 10 -B 5 "button.*Yes\|button.*No" test-results/*/error-context.md
|
||||
```
|
||||
|
||||
### 3. Check Trace Files
|
||||
```bash
|
||||
# List available trace files
|
||||
find test-results -name "*.zip" | grep trace
|
||||
|
||||
# View trace in browser
|
||||
npx playwright show-trace test-results/test-name/trace.zip
|
||||
```
|
||||
|
||||
### 4. Investigate Selector Issues
|
||||
```typescript
|
||||
// Check for multiple elements with same text
|
||||
await page.locator('button:has-text("Yes")').count(); // Should be 1
|
||||
|
||||
// Use more specific selectors
|
||||
await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes")').click();
|
||||
```
|
||||
|
||||
## What Works (Evidence)
|
||||
- ✅ **Error context files** provide page snapshots showing exact DOM state at failure
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: `test-results/60-new-activity-New-offers-for-another-user-chromium/error-context.md` shows both alerts visible
|
||||
- **Verify at**: Error context files in test results directory
|
||||
|
||||
- ✅ **Trace files** capture detailed execution sequence for failed tests
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: `trace.zip` files available for all failed tests
|
||||
- **Verify at**: Use `npx playwright show-trace <filename>`
|
||||
|
||||
- ✅ **Page snapshots** reveal UI conflicts like multiple alerts with duplicate button text
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: YAML snapshots show registration + export alerts simultaneously
|
||||
- **Verify at**: Error context markdown files
|
||||
|
||||
## What Doesn't (Evidence & Hypotheses)
|
||||
- ❌ **Generic selectors** fail with multiple similar elements at `test-playwright/testUtils.ts:161`
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: `button:has-text("Yes")` matches both "Yes" and "Yes, Export Data"
|
||||
- **Hypothesis**: Selector ambiguity due to multiple alerts with conflicting button text
|
||||
- **Next probe**: Use more specific selectors or dismiss alerts sequentially
|
||||
|
||||
- ❌ **Timing-dependent tests** fail due to alert stacking at `src/views/ContactsView.vue:860,1283`
|
||||
- **Time**: 2025-08-21T14:22Z
|
||||
- **Evidence**: Both alerts use identical 1000ms delays, ensuring simultaneous display
|
||||
- **Hypothesis**: Race condition between alert displays creates UI conflicts
|
||||
- **Next probe**: Implement alert queuing or prevent overlapping alerts
|
||||
|
||||
## Risks, Limits, Assumptions
|
||||
- **Trace file size**: Large trace files may impact storage and analysis time
|
||||
- **Browser compatibility**: Trace viewer requires specific browser support
|
||||
- **Test isolation**: Shared state between tests may affect investigation results
|
||||
- **Timing sensitivity**: Tests may pass/fail based on system performance
|
||||
|
||||
## Next Steps
|
||||
| Owner | Task | Exit Criteria | Target Date (UTC) |
|
||||
|---|---|---|---|
|
||||
| Development Team | Fix test selectors for multiple alerts | All tests pass consistently | 2025-08-22 |
|
||||
| Development Team | Implement alert queuing system | No overlapping alerts with conflicting buttons | 2025-08-25 |
|
||||
| Development Team | Add test IDs to alert buttons | Unique selectors for all UI elements | 2025-08-28 |
|
||||
|
||||
## References
|
||||
- [Playwright Trace Viewer Documentation](https://playwright.dev/docs/trace-viewer)
|
||||
- [Playwright Test Results](https://playwright.dev/docs/test-reporters)
|
||||
- [Test Investigation Workflow](./research_diagnostic.mdc)
|
||||
|
||||
## Competence Hooks
|
||||
- **Why this works**: Systematic investigation leverages Playwright's built-in debugging tools to identify root causes
|
||||
- **Common pitfalls**: Generic selectors fail with multiple similar elements; timing issues create race conditions; alert stacking causes UI conflicts
|
||||
- **Next skill unlock**: Implement unique test IDs and handle alert dismissal order in test flows
|
||||
- **Teach-back**: "How would you investigate a Playwright test failure using error context, trace files, and page snapshots?"
|
||||
|
||||
## Collaboration Hooks
|
||||
- **Reviewers**: QA team, test automation engineers
|
||||
- **Sign-off checklist**: Error context analyzed, trace files reviewed, root cause identified, fix implemented and tested
|
||||
|
||||
## Assumptions & Limits
|
||||
- Test results directory structure follows Playwright conventions
|
||||
- Trace files are enabled in configuration (`trace: "retain-on-failure"`)
|
||||
- Error context files contain valid YAML page snapshots
|
||||
- Browser environment supports trace viewer functionality
|
||||
|
||||
---
|
||||
|
||||
**Status**: Active investigation directive
|
||||
**Priority**: High
|
||||
**Maintainer**: Development team
|
||||
**Next Review**: 2025-09-21
|
||||
@@ -5,7 +5,7 @@
|
||||
**Status**: 🎯 **ACTIVE** - Version control guidelines
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 0) let the developer control git
|
||||
### 1) Version-Control Ownership
|
||||
|
||||
- **MUST NOT** run `git add`, `git commit`, or any write action.
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -54,6 +54,9 @@ build_logs/
|
||||
# Guard feedback logs (for continuous improvement analysis)
|
||||
.guard-feedback.log
|
||||
|
||||
# Workflow state file (contains dynamic state, not version controlled)
|
||||
.cursor/rules/.workflow_state.json
|
||||
|
||||
# PWA icon files generated by capacitor-assets
|
||||
icons
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ echo "🔍 Running pre-commit hooks..."
|
||||
|
||||
# Run lint-fix first
|
||||
echo "📝 Running lint-fix..."
|
||||
|
||||
# Capture git status before lint-fix to detect changes
|
||||
git_status_before=$(git status --porcelain)
|
||||
|
||||
npm run lint-fix || {
|
||||
echo
|
||||
echo "❌ Linting failed. Please fix the issues and try again."
|
||||
@@ -18,16 +22,47 @@ npm run lint-fix || {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if lint-fix made any changes
|
||||
git_status_after=$(git status --porcelain)
|
||||
|
||||
if [ "$git_status_before" != "$git_status_after" ]; then
|
||||
echo
|
||||
echo "⚠️ lint-fix made changes to your files!"
|
||||
echo "📋 Changes detected:"
|
||||
git diff --name-only
|
||||
echo
|
||||
echo "❓ What would you like to do?"
|
||||
echo " [c] Continue commit without the new changes"
|
||||
echo " [a] Abort commit (recommended - review and stage the changes)"
|
||||
echo
|
||||
printf "Choose [c/a]: "
|
||||
# The `< /dev/tty` is necessary to make read work in git's non-interactive shell
|
||||
read choice < /dev/tty
|
||||
|
||||
case $choice in
|
||||
[Cc]* )
|
||||
echo "✅ Continuing commit without lint-fix changes..."
|
||||
sleep 3
|
||||
;;
|
||||
[Aa]* | * )
|
||||
echo "🛑 Commit aborted. Please review the changes made by lint-fix."
|
||||
echo "💡 You can stage the changes with 'git add .' and commit again."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Then run Build Architecture Guard
|
||||
echo "🏗️ Running Build Architecture Guard..."
|
||||
bash ./scripts/build-arch-guard.sh --staged || {
|
||||
echo
|
||||
echo "❌ Build Architecture Guard failed. Please fix the issues and try again."
|
||||
echo "💡 To bypass this check for emergency commits, use:"
|
||||
echo " git commit --no-verify"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
#echo "🏗️ Running Build Architecture Guard..."
|
||||
#bash ./scripts/build-arch-guard.sh --staged || {
|
||||
# echo
|
||||
# echo "❌ Build Architecture Guard failed. Please fix the issues and try again."
|
||||
# echo "💡 To bypass this check for emergency commits, use:"
|
||||
# echo " git commit --no-verify"
|
||||
# echo
|
||||
# exit 1
|
||||
#}
|
||||
|
||||
echo "✅ All pre-commit checks passed!"
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ else
|
||||
RANGE="HEAD~1..HEAD"
|
||||
fi
|
||||
|
||||
bash ./scripts/build-arch-guard.sh --range "$RANGE" || {
|
||||
echo
|
||||
echo "💡 To bypass this check for emergency pushes, use:"
|
||||
echo " git push --no-verify"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
#bash ./scripts/build-arch-guard.sh --range "$RANGE" || {
|
||||
# echo
|
||||
# echo "💡 To bypass this check for emergency pushes, use:"
|
||||
# echo " git push --no-verify"
|
||||
# echo
|
||||
# exit 1
|
||||
#}
|
||||
|
||||
130
BUILDING.md
130
BUILDING.md
@@ -251,7 +251,7 @@ npm run build:web:dev # Start development server with hot reload
|
||||
npm run build:web # Development build (starts dev server with hot reload)
|
||||
npm run build:web:test # Test environment build (optimized for testing)
|
||||
npm run build:web:prod # Production build (optimized for production)
|
||||
npm run build:web:serve # Build and serve locally (builds then serves)
|
||||
npm run build:web:serve # Build and serve locally for production testing
|
||||
|
||||
# Docker builds
|
||||
npm run build:web:docker # Development build with Docker containerization
|
||||
@@ -269,6 +269,12 @@ Start the development server using `npm run build:web:dev` or `npm run build:web
|
||||
2. The built files will be in the `dist` directory
|
||||
3. To test the production build locally, use `npm run build:web:serve` (builds then serves)
|
||||
|
||||
**Why Use `serve`?**
|
||||
- **Production Testing**: Test your optimized production build locally before deployment
|
||||
- **SPA Routing Validation**: Verify deep linking and navigation work correctly (handles routes like `/discover`, `/account`)
|
||||
- **Performance Testing**: Test the minified and optimized build locally
|
||||
- **Deployment Validation**: Ensure built files work correctly when served by a real HTTP server
|
||||
|
||||
You'll likely want to use test locations for the Endorser & image & partner servers; see "DEFAULT_ENDORSER_API_SERVER" & "DEFAULT_IMAGE_API_SERVER" & "DEFAULT_PARTNER_API_SERVER" below.
|
||||
|
||||
### Web Build Script Details
|
||||
@@ -288,7 +294,7 @@ All web build commands use the `./scripts/build-web.sh` script, which provides:
|
||||
- **Clean Build**: Removes previous `dist/` directory
|
||||
- **Vite Build**: Executes `npx vite build --config vite.config.web.mts`
|
||||
- **Docker Support**: Optional Docker containerization
|
||||
- **Local Serving**: Built-in HTTP server for testing builds
|
||||
- **Local Serving**: Built-in HTTP server for testing builds with SPA routing support
|
||||
|
||||
**Direct Script Usage:**
|
||||
|
||||
@@ -324,6 +330,25 @@ All web build commands use the `./scripts/build-web.sh` script, which provides:
|
||||
- `5` - Serve command failed
|
||||
- `6` - Invalid build mode
|
||||
|
||||
### Local Serving with `serve`
|
||||
|
||||
The `serve` functionality provides a local HTTP server for testing production builds:
|
||||
|
||||
**What It Does:**
|
||||
1. **Builds** the application using Vite
|
||||
2. **Serves** the built files from the `dist/` directory
|
||||
3. **Handles SPA Routing** - serves `index.html` for all routes (fixes 404s on `/discover`, `/account`, etc.)
|
||||
|
||||
**Server Options:**
|
||||
- **Primary**: `npx serve -s dist -l 8080` (recommended - full SPA support)
|
||||
- **Fallback**: Python HTTP server (limited SPA routing support)
|
||||
|
||||
**Use Cases:**
|
||||
- Testing production builds before deployment
|
||||
- Validating SPA routing behavior
|
||||
- Performance testing of optimized builds
|
||||
- Debugging production build issues locally
|
||||
|
||||
### Compile and minify for test & production
|
||||
|
||||
- If there are DB changes: before updating the test server, open browser(s) with
|
||||
@@ -592,7 +617,8 @@ The Electron build process follows a multi-stage approach:
|
||||
#### **Stage 2: Capacitor Sync**
|
||||
|
||||
- Copies web assets to Electron app directory
|
||||
- Syncs Capacitor configuration and plugins
|
||||
- Uses Electron-specific Capacitor configuration (not copied from main config)
|
||||
- Syncs Capacitor plugins for Electron platform
|
||||
- Prepares native module bindings
|
||||
|
||||
#### **Stage 3: TypeScript Compile**
|
||||
@@ -1125,28 +1151,28 @@ If you need to build manually or want to understand the individual steps:
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx:
|
||||
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
shortened_path="${gem_path:h:h}"
|
||||
export GEM_HOME=$shortened_path
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
```bash
|
||||
gem_path=$(which gem)
|
||||
shortened_path="${gem_path:h:h}"
|
||||
export GEM_HOME=$shortened_path
|
||||
export GEM_PATH=$shortened_path
|
||||
```
|
||||
|
||||
##### 1. Bump the version in package.json, then here
|
||||
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
|
||||
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 40 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.7;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
Here's prod. Also available: test, dev
|
||||
Here's prod. Also available: test, dev
|
||||
|
||||
```bash
|
||||
npm run build:ios:prod
|
||||
```
|
||||
```bash
|
||||
npm run build:ios:prod
|
||||
```
|
||||
|
||||
3.1. Use Xcode to build and run on simulator or device.
|
||||
|
||||
@@ -1171,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
|
||||
- It can take 15 minutes for the build to show up in the list of builds.
|
||||
- You'll probably have to "Manage" something about encryption, disallowed in France.
|
||||
- Then "Save" and "Add to Review" and "Resubmit to App Review".
|
||||
- Eventually it'll be "Ready for Distribution" which means
|
||||
- Eventually it'll be "Ready for Distribution" which means it's live
|
||||
- When finished, bump package.json version
|
||||
|
||||
### Android Build
|
||||
|
||||
@@ -1289,26 +1316,26 @@ The recommended way to build for Android is using the automated build script:
|
||||
|
||||
#### Android Manual Build Process
|
||||
|
||||
##### 1. Bump the version in package.json, then here: android/app/build.gradle
|
||||
##### 1. Bump the version in package.json, then update these versions & run:
|
||||
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 40/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.0.7"/g' android/app/build.gradle
|
||||
```
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
Here's prod. Also available: test, dev
|
||||
|
||||
```bash
|
||||
npm run build:android:prod
|
||||
```
|
||||
```bash
|
||||
npm run build:android:prod
|
||||
```
|
||||
|
||||
##### 3. Open the project in Android Studio
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
##### 4. Use Android Studio to build and run on emulator or device
|
||||
|
||||
@@ -1353,6 +1380,8 @@ At play.google.com/console:
|
||||
- Note that if you add testers, you have to go to "Publishing Overview" and send
|
||||
those changes or your (closed) testers won't see it.
|
||||
|
||||
- When finished, bump package.json version
|
||||
|
||||
### Capacitor Operations
|
||||
|
||||
```bash
|
||||
@@ -2743,6 +2772,45 @@ configuration files in the repository.
|
||||
|
||||
---
|
||||
|
||||
### 2025-08-26 - Capacitor Plugin Additions
|
||||
|
||||
#### New Capacitor Plugins Added
|
||||
- **Added**: `@capacitor/clipboard` v6.0.2 - Clipboard functionality for mobile platforms
|
||||
- **Purpose**: Enable copy/paste operations on mobile devices
|
||||
- **Platforms**: iOS and Android
|
||||
- **Features**: Read/write clipboard content, text handling
|
||||
- **Integration**: Automatically included in mobile builds
|
||||
|
||||
- **Added**: `@capacitor/status-bar` v6.0.2 - Status bar management for mobile platforms
|
||||
- **Purpose**: Control mobile device status bar appearance and behavior
|
||||
- **Platforms**: iOS and Android
|
||||
- **Features**: Status bar styling, visibility control, color management
|
||||
- **Integration**: Automatically included in mobile builds
|
||||
|
||||
#### Android Build System Updates
|
||||
- **Modified**: `android/capacitor.settings.gradle` - Added new plugin project includes
|
||||
- **Added**: `:capacitor-clipboard` project directory mapping
|
||||
- **Added**: `:capacitor-status-bar` project directory mapping
|
||||
- **Impact**: New plugins now properly integrated into Android build process
|
||||
|
||||
#### Package Dependencies
|
||||
- **Updated**: `package.json` - Added new Capacitor plugin dependencies
|
||||
- **Updated**: `package-lock.json` - Locked dependency versions for consistency
|
||||
- **Version**: All new plugins use Capacitor 6.x compatible versions
|
||||
|
||||
#### Build Process Impact
|
||||
- **No Breaking Changes**: Existing build commands continue to work unchanged
|
||||
- **Enhanced Mobile Features**: New clipboard and status bar capabilities available
|
||||
- **Automatic Integration**: Plugins automatically included in mobile builds
|
||||
- **Platform Support**: Both iOS and Android builds now include new functionality
|
||||
|
||||
#### Testing Requirements
|
||||
- **Mobile Builds**: Verify new plugins integrate correctly in iOS and Android builds
|
||||
- **Functionality**: Test clipboard operations and status bar management on devices
|
||||
- **Fallback**: Ensure graceful degradation when plugins are unavailable
|
||||
|
||||
---
|
||||
|
||||
**Note**: This documentation is maintained alongside the build system. For the
|
||||
most up-to-date information, refer to the actual script files and Vite
|
||||
configuration files in the repository.
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [1.1.2] - 2025.11.06
|
||||
|
||||
### Fixed
|
||||
- Bad page when user follows prompt to backup seed
|
||||
|
||||
|
||||
## [1.1.1] - 2025.11.03
|
||||
|
||||
### Added
|
||||
- Meeting onboarding via prompts
|
||||
- Emojis on gift feed
|
||||
- Starred projects with notification
|
||||
|
||||
|
||||
## [1.0.7] - 2025.08.18
|
||||
|
||||
### Fixed
|
||||
|
||||
852
CODE_QUALITY_DEEP_ANALYSIS.md
Normal file
852
CODE_QUALITY_DEEP_ANALYSIS.md
Normal file
@@ -0,0 +1,852 @@
|
||||
# TimeSafari Code Quality: Comprehensive Deep Analysis
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: Tue Sep 16 05:22:10 AM UTC 2025
|
||||
**Status**: 🎯 **COMPREHENSIVE ANALYSIS** - Complete code quality assessment with actionable recommendations
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The TimeSafari codebase demonstrates **exceptional code quality** with mature patterns, minimal technical debt, and excellent separation of concerns. This comprehensive analysis covers **291 source files** totaling **104,527 lines** of code, including detailed examination of **94 Vue components and views**.
|
||||
|
||||
**Key Quality Metrics:**
|
||||
- **Technical Debt**: Extremely low (6 TODO/FIXME comments across entire codebase)
|
||||
- **Database Migration**: 99.5% complete (1 remaining legacy import)
|
||||
- **File Complexity**: High variance (largest file: 2,215 lines)
|
||||
- **Type Safety**: Mixed patterns (41 "as any" assertions in Vue files, 62 total)
|
||||
- **Error Handling**: Comprehensive (367 catch blocks with good coverage)
|
||||
- **Architecture**: Consistent Vue 3 Composition API with TypeScript
|
||||
|
||||
## Vue Components & Views Analysis (94 Files)
|
||||
|
||||
### Component Analysis (40 Components)
|
||||
|
||||
#### Component Size Distribution
|
||||
```
|
||||
Large Components (>500 lines): 5 components (12.5%)
|
||||
├── ImageMethodDialog.vue (947 lines) 🔴 CRITICAL
|
||||
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
|
||||
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
|
||||
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
|
||||
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
|
||||
|
||||
Medium Components (200-500 lines): 12 components (30%)
|
||||
├── GiftDetailsStep.vue (450 lines)
|
||||
├── EntityGrid.vue (348 lines)
|
||||
├── ActivityListItem.vue (334 lines)
|
||||
├── OfferDialog.vue (327 lines)
|
||||
├── OnboardingDialog.vue (314 lines)
|
||||
├── EntitySelectionStep.vue (313 lines)
|
||||
├── GiftedPrompts.vue (293 lines)
|
||||
├── ChoiceButtonDialog.vue (250 lines)
|
||||
├── DataExportSection.vue (251 lines)
|
||||
├── AmountInput.vue (224 lines)
|
||||
├── HiddenDidDialog.vue (220 lines)
|
||||
└── FeedFilters.vue (218 lines)
|
||||
|
||||
Small Components (<200 lines): 23 components (57.5%)
|
||||
├── ContactListItem.vue (217 lines)
|
||||
├── EntitySummaryButton.vue (202 lines)
|
||||
├── IdentitySection.vue (186 lines)
|
||||
├── ContactInputForm.vue (173 lines)
|
||||
├── SpecialEntityCard.vue (156 lines)
|
||||
├── RegistrationNotice.vue (154 lines)
|
||||
├── ContactNameDialog.vue (154 lines)
|
||||
├── PersonCard.vue (153 lines)
|
||||
├── UserNameDialog.vue (147 lines)
|
||||
├── InfiniteScroll.vue (132 lines)
|
||||
├── LocationSearchSection.vue (124 lines)
|
||||
├── UsageLimitsSection.vue (123 lines)
|
||||
├── QuickNav.vue (118 lines)
|
||||
├── ProjectCard.vue (104 lines)
|
||||
├── ContactListHeader.vue (101 lines)
|
||||
├── TopMessage.vue (98 lines)
|
||||
├── InviteDialog.vue (95 lines)
|
||||
├── ImageViewer.vue (94 lines)
|
||||
├── EntityIcon.vue (86 lines)
|
||||
├── ShowAllCard.vue (66 lines)
|
||||
├── ContactBulkActions.vue (53 lines)
|
||||
├── ProjectIcon.vue (47 lines)
|
||||
└── LargeIdenticonModal.vue (44 lines)
|
||||
```
|
||||
|
||||
#### Critical Component Analysis
|
||||
|
||||
**1. `ImageMethodDialog.vue` (947 lines) 🔴 CRITICAL REFACTORING NEEDED**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Excessive Single Responsibility**: Handles camera preview, file upload, URL input, cropping, diagnostics, and error handling
|
||||
- **Complex State Management**: 20+ reactive properties with interdependencies
|
||||
- **Mixed Concerns**: Camera API, file handling, UI state, and business logic intertwined
|
||||
- **Template Complexity**: ~300 lines of template with deeply nested conditions
|
||||
|
||||
**Refactoring Strategy:**
|
||||
```typescript
|
||||
// Current monolithic structure
|
||||
ImageMethodDialog.vue (947 lines) {
|
||||
CameraPreview: ~200 lines
|
||||
FileUpload: ~150 lines
|
||||
URLInput: ~100 lines
|
||||
CroppingInterface: ~200 lines
|
||||
DiagnosticsPanel: ~150 lines
|
||||
ErrorHandling: ~100 lines
|
||||
StateManagement: ~47 lines
|
||||
}
|
||||
|
||||
// Proposed component decomposition
|
||||
ImageMethodDialog.vue (coordinator, ~200 lines)
|
||||
├── CameraPreviewComponent.vue (~250 lines)
|
||||
├── FileUploadComponent.vue (~150 lines)
|
||||
├── URLInputComponent.vue (~100 lines)
|
||||
├── ImageCropperComponent.vue (~200 lines)
|
||||
├── DiagnosticsPanelComponent.vue (~150 lines)
|
||||
└── ImageUploadErrorHandler.vue (~100 lines)
|
||||
```
|
||||
|
||||
**2. `GiftedDialog.vue` (670 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Assessment**: **GOOD** - Already partially refactored with step components extracted.
|
||||
|
||||
**3. `PhotoDialog.vue` (669 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues**: Similar to ImageMethodDialog with significant code duplication.
|
||||
|
||||
**4. `PushNotificationPermission.vue` (660 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues**: Complex permission logic with platform-specific code mixed together.
|
||||
|
||||
### View Analysis (54 Views)
|
||||
|
||||
#### View Size Distribution
|
||||
```
|
||||
Large Views (>1000 lines): 9 views (16.7%)
|
||||
├── AccountViewView.vue (2,215 lines) 🔴 CRITICAL
|
||||
├── HomeView.vue (1,852 lines) ⚠️ HIGH PRIORITY
|
||||
├── ProjectViewView.vue (1,479 lines) ⚠️ HIGH PRIORITY
|
||||
├── DatabaseMigration.vue (1,438 lines) ⚠️ HIGH PRIORITY
|
||||
├── ContactsView.vue (1,382 lines) ⚠️ HIGH PRIORITY
|
||||
├── TestView.vue (1,259 lines) ⚠️ MODERATE PRIORITY
|
||||
├── ClaimView.vue (1,225 lines) ⚠️ MODERATE PRIORITY
|
||||
├── NewEditProjectView.vue (957 lines) ⚠️ MODERATE PRIORITY
|
||||
└── ContactQRScanShowView.vue (929 lines) ⚠️ MODERATE PRIORITY
|
||||
|
||||
Medium Views (500-1000 lines): 8 views (14.8%)
|
||||
├── ConfirmGiftView.vue (898 lines)
|
||||
├── DiscoverView.vue (888 lines)
|
||||
├── DIDView.vue (848 lines)
|
||||
├── GiftedDetailsView.vue (840 lines)
|
||||
├── OfferDetailsView.vue (781 lines)
|
||||
├── HelpView.vue (780 lines)
|
||||
├── ProjectsView.vue (742 lines)
|
||||
└── ContactQRScanFullView.vue (701 lines)
|
||||
|
||||
Small Views (<500 lines): 37 views (68.5%)
|
||||
├── OnboardMeetingSetupView.vue (687 lines)
|
||||
├── ContactImportView.vue (568 lines)
|
||||
├── HelpNotificationsView.vue (566 lines)
|
||||
├── OnboardMeetingListView.vue (507 lines)
|
||||
├── InviteOneView.vue (475 lines)
|
||||
├── QuickActionBvcEndView.vue (442 lines)
|
||||
├── ContactAmountsView.vue (416 lines)
|
||||
├── SearchAreaView.vue (384 lines)
|
||||
├── SharedPhotoView.vue (379 lines)
|
||||
├── ContactGiftingView.vue (373 lines)
|
||||
├── ContactEditView.vue (345 lines)
|
||||
├── IdentitySwitcherView.vue (324 lines)
|
||||
├── UserProfileView.vue (323 lines)
|
||||
├── NewActivityView.vue (323 lines)
|
||||
├── QuickActionBvcBeginView.vue (303 lines)
|
||||
├── SeedBackupView.vue (292 lines)
|
||||
├── InviteOneAcceptView.vue (292 lines)
|
||||
├── ClaimCertificateView.vue (279 lines)
|
||||
├── StartView.vue (271 lines)
|
||||
├── ImportAccountView.vue (265 lines)
|
||||
├── ClaimAddRawView.vue (249 lines)
|
||||
├── OnboardMeetingMembersView.vue (247 lines)
|
||||
├── DeepLinkErrorView.vue (239 lines)
|
||||
├── ClaimReportCertificateView.vue (236 lines)
|
||||
├── DeepLinkRedirectView.vue (219 lines)
|
||||
├── ImportDerivedAccountView.vue (207 lines)
|
||||
├── ShareMyContactInfoView.vue (196 lines)
|
||||
├── RecentOffersToUserProjectsView.vue (176 lines)
|
||||
├── RecentOffersToUserView.vue (166 lines)
|
||||
├── NewEditAccountView.vue (142 lines)
|
||||
├── StatisticsView.vue (133 lines)
|
||||
├── HelpOnboardingView.vue (118 lines)
|
||||
├── LogView.vue (104 lines)
|
||||
├── NewIdentifierView.vue (97 lines)
|
||||
├── HelpNotificationTypesView.vue (73 lines)
|
||||
├── ConfirmContactView.vue (57 lines)
|
||||
└── QuickActionBvcView.vue (54 lines)
|
||||
```
|
||||
|
||||
#### Critical View Analysis
|
||||
|
||||
**1. `AccountViewView.vue` (2,215 lines) 🔴 CRITICAL REFACTORING NEEDED**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Monolithic Architecture**: Handles 7 distinct concerns in single file
|
||||
- **Template Complexity**: ~750 lines of template with deeply nested conditions
|
||||
- **Method Proliferation**: 50+ methods handling disparate concerns
|
||||
- **State Management**: 25+ reactive properties without clear organization
|
||||
|
||||
**Refactoring Strategy:**
|
||||
```typescript
|
||||
// Current monolithic structure
|
||||
AccountViewView.vue (2,215 lines) {
|
||||
ProfileSection: ~400 lines
|
||||
SettingsSection: ~300 lines
|
||||
NotificationSection: ~200 lines
|
||||
ServerConfigSection: ~250 lines
|
||||
ExportImportSection: ~300 lines
|
||||
LimitsSection: ~150 lines
|
||||
MapSection: ~200 lines
|
||||
StateManagement: ~415 lines
|
||||
}
|
||||
|
||||
// Proposed component extraction
|
||||
AccountViewView.vue (coordinator, ~400 lines)
|
||||
├── ProfileManagementSection.vue (~300 lines)
|
||||
├── ServerConfigurationSection.vue (~250 lines)
|
||||
├── NotificationSettingsSection.vue (~200 lines)
|
||||
├── DataExportImportSection.vue (~300 lines)
|
||||
├── UsageLimitsDisplay.vue (~150 lines)
|
||||
├── LocationProfileSection.vue (~200 lines)
|
||||
└── AccountViewStateManager.ts (~200 lines)
|
||||
```
|
||||
|
||||
**2. `HomeView.vue` (1,852 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Multiple Concerns**: Activity feed, projects, contacts, notifications in one file
|
||||
- **Complex State Management**: 20+ reactive properties with interdependencies
|
||||
- **Mixed Lifecycle Logic**: Mount, update, and destroy logic intertwined
|
||||
|
||||
**3. `ProjectViewView.vue` (1,479 lines) ⚠️ HIGH PRIORITY**
|
||||
|
||||
**Issues Identified:**
|
||||
- **Project Management Complexity**: Handles project details, members, offers, and activities
|
||||
- **Mixed Concerns**: Project data, member management, and activity feed in single view
|
||||
|
||||
### Vue Component Quality Patterns
|
||||
|
||||
#### Excellent Patterns Found:
|
||||
|
||||
**1. EntityIcon.vue (86 lines) ✅ EXCELLENT**
|
||||
```typescript
|
||||
// Clean, focused responsibility
|
||||
@Component({ name: "EntityIcon" })
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop() contact?: Contact;
|
||||
@Prop({ default: "" }) entityId!: string;
|
||||
@Prop({ default: 0 }) iconSize!: number;
|
||||
|
||||
generateIcon(): string {
|
||||
// Clear priority order: profile image → avatar → fallback
|
||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
if (imageUrl) return `<img src="${imageUrl}" ... />`;
|
||||
|
||||
const identifier = this.contact?.did || this.entityId;
|
||||
if (!identifier) return `<img src="${blankSquareSvg}" ... />`;
|
||||
|
||||
return createAvatar(avataaars, { seed: identifier, size: this.iconSize }).toString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. QuickNav.vue (118 lines) ✅ EXCELLENT**
|
||||
```typescript
|
||||
// Simple, focused navigation component
|
||||
@Component({ name: "QuickNav" })
|
||||
export default class QuickNav extends Vue {
|
||||
@Prop selected = "";
|
||||
|
||||
// Clean template with consistent patterns
|
||||
// Proper accessibility attributes
|
||||
// Responsive design with safe area handling
|
||||
}
|
||||
```
|
||||
|
||||
**3. Small Focused Views ✅ EXCELLENT**
|
||||
```typescript
|
||||
// QuickActionBvcView.vue (54 lines) - Perfect size
|
||||
// ConfirmContactView.vue (57 lines) - Focused responsibility
|
||||
// HelpNotificationTypesView.vue (73 lines) - Clear purpose
|
||||
// LogView.vue (104 lines) - Simple utility view
|
||||
```
|
||||
|
||||
#### Problematic Patterns Found:
|
||||
|
||||
**1. Excessive Props in Dialog Components**
|
||||
```typescript
|
||||
// GiftedDialog.vue - Too many props
|
||||
@Prop() fromProjectId = "";
|
||||
@Prop() toProjectId = "";
|
||||
@Prop() isFromProjectView = false;
|
||||
@Prop() hideShowAll = false;
|
||||
@Prop({ default: "person" }) giverEntityType = "person";
|
||||
@Prop({ default: "person" }) recipientEntityType = "person";
|
||||
// ... 10+ more props
|
||||
```
|
||||
|
||||
**2. Complex State Machines**
|
||||
```typescript
|
||||
// ImageMethodDialog.vue - Complex state management
|
||||
cameraState: "off" | "initializing" | "active" | "error" | "retrying" | "stopped" = "off";
|
||||
showCameraPreview = false;
|
||||
isRetrying = false;
|
||||
showDiagnostics = false;
|
||||
// ... 15+ more state properties
|
||||
```
|
||||
|
||||
**3. Excessive Reactive Properties**
|
||||
```typescript
|
||||
// AccountViewView.vue - Too many reactive properties
|
||||
downloadUrl: string = "";
|
||||
loadingLimits: boolean = false;
|
||||
loadingProfile: boolean = true;
|
||||
showAdvanced: boolean = false;
|
||||
showB64Copy: boolean = false;
|
||||
showContactGives: boolean = false;
|
||||
showDidCopy: boolean = false;
|
||||
showDerCopy: boolean = false;
|
||||
showGeneralAdvanced: boolean = false;
|
||||
showLargeIdenticonId?: string;
|
||||
showLargeIdenticonUrl?: string;
|
||||
showPubCopy: boolean = false;
|
||||
showShortcutBvc: boolean = false;
|
||||
warnIfProdServer: boolean = false;
|
||||
warnIfTestServer: boolean = false;
|
||||
zoom: number = 2;
|
||||
isMapReady: boolean = false;
|
||||
// ... 10+ more properties
|
||||
```
|
||||
|
||||
## File Size and Complexity Analysis (All Files)
|
||||
|
||||
### Problematic Large Files
|
||||
|
||||
#### 1. `AccountViewView.vue` (2,215 lines) 🔴 **CRITICAL**
|
||||
**Issues Identified:**
|
||||
- **Excessive Single File Responsibility**: Handles profile, settings, notifications, server configuration, export/import, limits checking
|
||||
- **Template Complexity**: ~750 lines of template with deeply nested conditions
|
||||
- **Method Proliferation**: 50+ methods handling disparate concerns
|
||||
- **State Management**: 25+ reactive properties without clear organization
|
||||
|
||||
#### 2. `PlatformServiceMixin.ts` (2,091 lines) ⚠️ **HIGH PRIORITY**
|
||||
**Issues Identified:**
|
||||
- **God Object Pattern**: Single file handling 80+ methods across multiple concerns
|
||||
- **Mixed Abstraction Levels**: Low-level SQL utilities mixed with high-level business logic
|
||||
- **Method Length Variance**: Some methods 100+ lines, others single-line wrappers
|
||||
|
||||
**Refactoring Strategy:**
|
||||
```typescript
|
||||
// Current monolithic mixin
|
||||
PlatformServiceMixin.ts (2,091 lines)
|
||||
|
||||
// Proposed separation of concerns
|
||||
├── CoreDatabaseMixin.ts // $db, $exec, $query, $first (200 lines)
|
||||
├── SettingsManagementMixin.ts // $settings, $saveSettings (400 lines)
|
||||
├── ContactManagementMixin.ts // $contacts, $insertContact (300 lines)
|
||||
├── EntityOperationsMixin.ts // $insertEntity, $updateEntity (400 lines)
|
||||
├── CachingMixin.ts // Cache management (150 lines)
|
||||
├── ActiveIdentityMixin.ts // Active DID management (200 lines)
|
||||
├── UtilityMixin.ts // Mapping, JSON parsing (200 lines)
|
||||
└── LoggingMixin.ts // $log, $logError (100 lines)
|
||||
```
|
||||
|
||||
#### 3. `HomeView.vue` (1,852 lines) ⚠️ **MODERATE PRIORITY**
|
||||
**Issues Identified:**
|
||||
- **Multiple Concerns**: Activity feed, projects, contacts, notifications in one file
|
||||
- **Complex State Management**: 20+ reactive properties with interdependencies
|
||||
- **Mixed Lifecycle Logic**: Mount, update, and destroy logic intertwined
|
||||
|
||||
### File Size Distribution Analysis
|
||||
```
|
||||
Files > 1000 lines: 9 files (4.6% of codebase)
|
||||
Files 500-1000 lines: 23 files (11.7% of codebase)
|
||||
Files 200-500 lines: 45 files (22.8% of codebase)
|
||||
Files < 200 lines: 120 files (60.9% of codebase)
|
||||
```
|
||||
|
||||
**Assessment**: Good distribution with most files reasonably sized, but critical outliers need attention.
|
||||
|
||||
## Type Safety Analysis
|
||||
|
||||
### Type Assertion Patterns
|
||||
|
||||
#### "as any" Usage (62 total instances) ⚠️
|
||||
|
||||
**Vue Components & Views (41 instances):**
|
||||
```typescript
|
||||
// ImageMethodDialog.vue:504
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
|
||||
// GiftedDialog.vue:228
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
|
||||
// AccountViewView.vue: Multiple instances for:
|
||||
// - PlatformServiceMixin method access
|
||||
// - Vue refs with complex typing
|
||||
// - External library integration (Leaflet)
|
||||
```
|
||||
|
||||
**Other Files (21 instances):**
|
||||
- **Vue Component References** (23 instances): `(this.$refs.dialog as any)`
|
||||
- **Platform Detection** (12 instances): `(navigator as any).standalone`
|
||||
- **External Library Integration** (15 instances): Leaflet, Axios extensions
|
||||
- **Legacy Code Compatibility** (8 instances): Temporary migration code
|
||||
- **Event Handler Workarounds** (4 instances): Vue event typing issues
|
||||
|
||||
**Example Problematic Pattern:**
|
||||
```typescript
|
||||
// src/views/AccountViewView.vue:934
|
||||
const iconDefault = L.Icon.Default.prototype as unknown as Record<string, unknown>;
|
||||
|
||||
// Better approach:
|
||||
interface LeafletIconPrototype {
|
||||
_getIconUrl?: unknown;
|
||||
}
|
||||
const iconDefault = L.Icon.Default.prototype as LeafletIconPrototype;
|
||||
```
|
||||
|
||||
#### "unknown" Type Usage (755 instances)
|
||||
**Analysis**: Generally good practice showing defensive programming, but some areas could benefit from more specific typing.
|
||||
|
||||
### Recommended Type Safety Improvements
|
||||
|
||||
1. **Create Interface Extensions**:
|
||||
```typescript
|
||||
// src/types/platform-service-mixin.ts
|
||||
interface VueWithPlatformServiceMixin extends Vue {
|
||||
$getActiveIdentity(): Promise<{ activeDid: string }>;
|
||||
$saveSettings(changes: Partial<Settings>): Promise<boolean>;
|
||||
// ... other methods
|
||||
}
|
||||
|
||||
// src/types/external.ts
|
||||
declare global {
|
||||
interface Navigator {
|
||||
standalone?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
interface VueRefWithOpen {
|
||||
open: (callback: (result?: unknown) => void) => void;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Component Ref Typing**:
|
||||
```typescript
|
||||
// Instead of: (this.$refs.dialog as any).open()
|
||||
// Use: (this.$refs.dialog as VueRefWithOpen).open()
|
||||
```
|
||||
|
||||
## Error Handling Consistency Analysis
|
||||
|
||||
### Error Handling Patterns (367 catch blocks)
|
||||
|
||||
#### Pattern Distribution:
|
||||
1. **Structured Logging** (85%): Uses logger.error with context
|
||||
2. **User Notification** (78%): Shows user-friendly error messages
|
||||
3. **Graceful Degradation** (92%): Provides fallback behavior
|
||||
4. **Error Propagation** (45%): Re-throws when appropriate
|
||||
|
||||
#### Excellent Pattern Example:
|
||||
```typescript
|
||||
// src/views/AccountViewView.vue:1617
|
||||
try {
|
||||
const response = await this.axios.delete(url, { headers });
|
||||
if (response.status === 204) {
|
||||
this.profileImageUrl = "";
|
||||
this.notify.success("Image deleted successfully.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isApiError(error) && error.response?.status === 404) {
|
||||
// Graceful handling - image already gone
|
||||
this.profileImageUrl = "";
|
||||
} else {
|
||||
this.notify.error("Failed to delete image", TIMEOUTS.STANDARD);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Areas for Improvement:
|
||||
1. **Inconsistent Error Typing**: Some catch(error: any), others catch(error: unknown)
|
||||
2. **Missing Error Boundaries**: No Vue error boundary components
|
||||
3. **Silent Failures**: 15% of catch blocks don't notify users
|
||||
|
||||
## Code Duplication Analysis
|
||||
|
||||
### Significant Duplication Patterns
|
||||
|
||||
#### 1. **Toggle Component Pattern** (12 occurrences)
|
||||
```html
|
||||
<!-- Repeated across multiple files -->
|
||||
<div class="relative ml-2 cursor-pointer" @click="toggleMethod()">
|
||||
<input v-model="property" type="checkbox" class="sr-only" />
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Solution**: Create `ToggleSwitch.vue` component with props for value, label, and change handler.
|
||||
|
||||
#### 2. **API Error Handling Pattern** (25 occurrences)
|
||||
```typescript
|
||||
try {
|
||||
const response = await this.axios.post(url, data, { headers });
|
||||
if (response.status === 200) {
|
||||
this.notify.success("Operation successful");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
this.notify.error(`Failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Create `ApiRequestMixin.ts` with standardized request/response handling.
|
||||
|
||||
#### 3. **Settings Update Pattern** (40+ occurrences)
|
||||
```typescript
|
||||
async methodName() {
|
||||
await this.$saveSettings({ property: this.newValue });
|
||||
this.property = this.newValue;
|
||||
}
|
||||
```
|
||||
|
||||
**Solution**: Enhanced PlatformServiceMixin already provides `$saveSettings()` - migrate remaining manual patterns.
|
||||
|
||||
## Dependency and Coupling Analysis
|
||||
|
||||
### Import Dependency Patterns
|
||||
|
||||
#### Legacy Database Coupling (EXCELLENT)
|
||||
- **Status**: 99.5% resolved (1 remaining databaseUtil import)
|
||||
- **Remaining**: `src/views/DeepLinkErrorView.vue:import { logConsoleAndDb }`
|
||||
- **Resolution**: Replace with PlatformServiceMixin `$logAndConsole()`
|
||||
|
||||
#### Circular Dependency Status (EXCELLENT)
|
||||
- **Status**: 100% resolved, no active circular dependencies
|
||||
- **Previous Issues**: All resolved through PlatformServiceMixin architecture
|
||||
|
||||
#### Component Coupling Analysis
|
||||
```typescript
|
||||
// High coupling components (>10 imports)
|
||||
AccountViewView.vue: 15 imports (understandable given scope)
|
||||
HomeView.vue: 12 imports
|
||||
ProjectViewView.vue: 11 imports
|
||||
|
||||
// Well-isolated components (<5 imports)
|
||||
QuickActionViews: 3-4 imports each
|
||||
Component utilities: 2-3 imports each
|
||||
```
|
||||
|
||||
**Assessment**: Reasonable coupling levels with clear architectural boundaries.
|
||||
|
||||
## Console Logging Analysis (129 instances)
|
||||
|
||||
### Logging Pattern Distribution:
|
||||
1. **console.log**: 89 instances (69%)
|
||||
2. **console.warn**: 24 instances (19%)
|
||||
3. **console.error**: 16 instances (12%)
|
||||
|
||||
### Vue Components & Views Logging (3 instances):
|
||||
- **Components**: 1 console.* call
|
||||
- **Views**: 2 console.* calls
|
||||
|
||||
### Inconsistent Logging Approach:
|
||||
```typescript
|
||||
// Mixed patterns found:
|
||||
console.log("Direct console logging"); // 89 instances
|
||||
logger.debug("Structured logging"); // Preferred pattern
|
||||
this.$logAndConsole("Mixin logging"); // PlatformServiceMixin
|
||||
```
|
||||
|
||||
### Recommended Standardization:
|
||||
1. **Migration Strategy**: Replace all console.* with logger.* calls
|
||||
2. **Structured Context**: Add consistent metadata to log entries
|
||||
3. **Log Levels**: Standardize debug/info/warn/error usage
|
||||
|
||||
## Technical Debt Analysis (6 total)
|
||||
|
||||
### Components (1 TODO):
|
||||
```typescript
|
||||
// PushNotificationPermission.vue
|
||||
// TODO: secretDB functionality needs to be migrated to PlatformServiceMixin
|
||||
```
|
||||
|
||||
### Views (2 TODOs):
|
||||
```typescript
|
||||
// AccountViewView.vue
|
||||
// TODO: Implement this for SQLite
|
||||
// TODO: implement this for SQLite
|
||||
```
|
||||
|
||||
### Other Files (3 TODOs):
|
||||
```typescript
|
||||
// src/db/tables/accounts.ts
|
||||
// TODO: When finished with migration, move these fields to Account and move identity and mnemonic here.
|
||||
|
||||
// src/util.d.ts
|
||||
// TODO: , inspect: inspect
|
||||
|
||||
// src/libs/crypto/vc/passkeyHelpers.ts
|
||||
// TODO: If it's after February 2025 when you read this then consider whether it still makes sense
|
||||
```
|
||||
|
||||
**Assessment**: **EXCELLENT** - Only 6 TODO comments across 291 files.
|
||||
|
||||
## Performance Anti-Patterns
|
||||
|
||||
### Identified Issues:
|
||||
|
||||
#### 1. **Excessive Reactive Properties**
|
||||
```typescript
|
||||
// AccountViewView.vue has 25+ reactive properties
|
||||
// Many could be computed or moved to component state
|
||||
```
|
||||
|
||||
#### 2. **Inline Method Calls in Templates**
|
||||
```html
|
||||
<!-- Anti-pattern: -->
|
||||
<span>{{ readableDate(timeStr) }}</span>
|
||||
|
||||
<!-- Better: -->
|
||||
<span>{{ readableTime }}</span>
|
||||
<!-- With computed property -->
|
||||
```
|
||||
|
||||
#### 3. **Missing Key Attributes in Lists**
|
||||
```html
|
||||
<!-- Several v-for loops missing :key attributes -->
|
||||
<li v-for="item in items">
|
||||
```
|
||||
|
||||
#### 4. **Complex Template Logic**
|
||||
```html
|
||||
<!-- AccountViewView.vue - Complex nested conditions -->
|
||||
<div v-if="!activeDid" id="noticeBeforeShare" class="bg-amber-200...">
|
||||
<p class="mb-4">
|
||||
<b>Note:</b> Before you can share with others or take any action, you need an identifier.
|
||||
</p>
|
||||
<router-link :to="{ name: 'new-identifier' }" class="inline-block...">
|
||||
Create An Identifier
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Identity Details -->
|
||||
<IdentitySection
|
||||
:given-name="givenName"
|
||||
:profile-image-url="profileImageUrl"
|
||||
:active-did="activeDid"
|
||||
:is-registered="isRegistered"
|
||||
:show-large-identicon-id="showLargeIdenticonId"
|
||||
:show-large-identicon-url="showLargeIdenticonUrl"
|
||||
:show-did-copy="showDidCopy"
|
||||
@edit-name="onEditName"
|
||||
@show-qr-code="onShowQrCode"
|
||||
@add-image="onAddImage"
|
||||
@delete-image="onDeleteImage"
|
||||
@show-large-identicon-id="onShowLargeIdenticonId"
|
||||
@show-large-identicon-url="onShowLargeIdenticonUrl"
|
||||
/>
|
||||
```
|
||||
|
||||
## Specific Actionable Recommendations
|
||||
|
||||
### Priority 1: Critical File Refactoring
|
||||
|
||||
1. **Split AccountViewView.vue**:
|
||||
- **Timeline**: 2-3 sprints
|
||||
- **Strategy**: Extract 6 major sections into focused components
|
||||
- **Risk**: Medium (requires careful state management coordination)
|
||||
- **Benefit**: Massive maintainability improvement, easier testing
|
||||
|
||||
2. **Decompose ImageMethodDialog.vue**:
|
||||
- **Timeline**: 2-3 sprints
|
||||
- **Strategy**: Extract 6 focused components (camera, file upload, cropping, etc.)
|
||||
- **Risk**: Medium (complex camera state management)
|
||||
- **Benefit**: Massive maintainability improvement
|
||||
|
||||
3. **Decompose PlatformServiceMixin.ts**:
|
||||
- **Timeline**: 1-2 sprints
|
||||
- **Strategy**: Create focused mixins by concern area
|
||||
- **Risk**: Low (well-defined interfaces already exist)
|
||||
- **Benefit**: Better code organization, reduced cognitive load
|
||||
|
||||
### Priority 2: Component Extraction
|
||||
|
||||
1. **HomeView.vue** → 4 focused sections
|
||||
- **Timeline**: 1-2 sprints
|
||||
- **Risk**: Low (clear separation of concerns)
|
||||
- **Benefit**: Better code organization
|
||||
|
||||
2. **ProjectViewView.vue** → 4 focused sections
|
||||
- **Timeline**: 1-2 sprints
|
||||
- **Risk**: Low (well-defined boundaries)
|
||||
- **Benefit**: Improved maintainability
|
||||
|
||||
### Priority 3: Shared Component Creation
|
||||
|
||||
1. **CameraPreviewComponent.vue**
|
||||
- Extract from ImageMethodDialog.vue and PhotoDialog.vue
|
||||
- **Benefit**: Eliminate code duplication
|
||||
|
||||
2. **FileUploadComponent.vue**
|
||||
- Extract from ImageMethodDialog.vue and PhotoDialog.vue
|
||||
- **Benefit**: Consistent file handling
|
||||
|
||||
3. **ToggleSwitch.vue**
|
||||
- Replace 12 duplicate toggle patterns
|
||||
- **Benefit**: Consistent UI components
|
||||
|
||||
4. **DiagnosticsPanelComponent.vue**
|
||||
- Extract from ImageMethodDialog.vue
|
||||
- **Benefit**: Reusable debugging component
|
||||
|
||||
### Priority 4: Type Safety Enhancement
|
||||
|
||||
1. **Eliminate "as any" Assertions**:
|
||||
- **Timeline**: 1 sprint
|
||||
- **Strategy**: Create proper interface extensions
|
||||
- **Risk**: Low
|
||||
- **Benefit**: Better compile-time error detection
|
||||
|
||||
2. **Standardize Error Typing**:
|
||||
- **Timeline**: 0.5 sprint
|
||||
- **Strategy**: Use consistent `catch (error: unknown)` pattern
|
||||
- **Risk**: None
|
||||
- **Benefit**: Better error handling consistency
|
||||
|
||||
### Priority 5: State Management Optimization
|
||||
|
||||
1. **Create Composables for Complex State**:
|
||||
```typescript
|
||||
// src/composables/useCameraState.ts
|
||||
export function useCameraState() {
|
||||
const cameraState = ref<CameraState>("off");
|
||||
const showPreview = ref(false);
|
||||
const isRetrying = ref(false);
|
||||
|
||||
const startCamera = async () => { /* ... */ };
|
||||
const stopCamera = () => { /* ... */ };
|
||||
|
||||
return { cameraState, showPreview, isRetrying, startCamera, stopCamera };
|
||||
}
|
||||
```
|
||||
|
||||
2. **Group Related Reactive Properties**:
|
||||
```typescript
|
||||
// Instead of:
|
||||
showB64Copy: boolean = false;
|
||||
showDidCopy: boolean = false;
|
||||
showDerCopy: boolean = false;
|
||||
showPubCopy: boolean = false;
|
||||
|
||||
// Use:
|
||||
copyStates = {
|
||||
b64: false,
|
||||
did: false,
|
||||
der: false,
|
||||
pub: false
|
||||
};
|
||||
```
|
||||
|
||||
### Priority 6: Code Standardization
|
||||
|
||||
1. **Logging Standardization**:
|
||||
- **Timeline**: 1 sprint
|
||||
- **Strategy**: Replace all console.* with logger.*
|
||||
- **Risk**: None
|
||||
- **Benefit**: Consistent logging, better debugging
|
||||
|
||||
2. **Template Optimization**:
|
||||
- Add missing `:key` attributes
|
||||
- Convert inline method calls to computed properties
|
||||
- Implement virtual scrolling for large lists
|
||||
|
||||
## Quality Metrics Summary
|
||||
|
||||
### Vue Component Quality Distribution:
|
||||
| Size Category | Count | Percentage | Quality Assessment |
|
||||
|---------------|-------|------------|-------------------|
|
||||
| Large (>500 lines) | 5 | 12.5% | 🔴 Needs Refactoring |
|
||||
| Medium (200-500 lines) | 12 | 30% | 🟡 Good with Minor Issues |
|
||||
| Small (<200 lines) | 23 | 57.5% | 🟢 Excellent |
|
||||
|
||||
### Vue View Quality Distribution:
|
||||
| Size Category | Count | Percentage | Quality Assessment |
|
||||
|---------------|-------|------------|-------------------|
|
||||
| Large (>1000 lines) | 9 | 16.7% | 🔴 Needs Refactoring |
|
||||
| Medium (500-1000 lines) | 8 | 14.8% | 🟡 Good with Minor Issues |
|
||||
| Small (<500 lines) | 37 | 68.5% | 🟢 Excellent |
|
||||
|
||||
### Overall Quality Metrics:
|
||||
| Metric | Components | Views | Overall Assessment |
|
||||
|--------|------------|-------|-------------------|
|
||||
| Technical Debt | 1 TODO | 2 TODOs | 🟢 Excellent |
|
||||
| Type Safety | 6 "as any" | 35 "as any" | 🟡 Good |
|
||||
| Console Logging | 1 instance | 2 instances | 🟢 Excellent |
|
||||
| Architecture Consistency | 100% | 100% | 🟢 Excellent |
|
||||
| Component Reuse | High | High | 🟢 Excellent |
|
||||
|
||||
### Before vs. Target State:
|
||||
| Metric | Current | Target | Status |
|
||||
|--------|---------|---------|---------|
|
||||
| Files >1000 lines | 9 files | 3 files | 🟡 Needs Work |
|
||||
| "as any" assertions | 62 | 15 | 🟡 Moderate |
|
||||
| Console.* calls | 129 | 0 | 🔴 Needs Work |
|
||||
| Component reuse | 40% | 75% | 🟡 Moderate |
|
||||
| Error consistency | 85% | 95% | 🟢 Good |
|
||||
| Type coverage | 88% | 95% | 🟢 Good |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk Improvements (High Impact):
|
||||
- Logging standardization
|
||||
- Type assertion cleanup
|
||||
- Missing key attributes
|
||||
- Component extraction from AccountViewView.vue
|
||||
- Shared component creation (ToggleSwitch, CameraPreview)
|
||||
|
||||
### Medium Risk Improvements:
|
||||
- PlatformServiceMixin decomposition
|
||||
- State management optimization
|
||||
- ImageMethodDialog decomposition
|
||||
|
||||
### High Risk Items:
|
||||
- None identified - project demonstrates excellent architectural discipline
|
||||
|
||||
## Conclusion
|
||||
|
||||
The TimeSafari codebase demonstrates **exceptional code quality** with:
|
||||
|
||||
**Key Strengths:**
|
||||
- **Consistent Architecture**: 100% Vue 3 Composition API with TypeScript
|
||||
- **Minimal Technical Debt**: Only 6 TODO comments across 291 files
|
||||
- **Excellent Small Components**: 68.5% of views and 57.5% of components are well-sized
|
||||
- **Strong Type Safety**: Minimal "as any" usage, mostly justified
|
||||
- **Clean Logging**: Minimal console.* usage, structured logging preferred
|
||||
- **Excellent Database Migration**: 99.5% complete
|
||||
- **Comprehensive Error Handling**: 367 catch blocks with good coverage
|
||||
- **No Circular Dependencies**: 100% resolved
|
||||
|
||||
**Primary Focus Areas:**
|
||||
1. **Decompose Large Files**: 5 components and 9 views need refactoring
|
||||
2. **Extract Shared Components**: Camera, file upload, and diagnostics components
|
||||
3. **Optimize State Management**: Group related properties and create composables
|
||||
4. **Improve Type Safety**: Create proper interface extensions for mixin methods
|
||||
5. **Logging Standardization**: Replace 129 console.* calls with structured logger.*
|
||||
|
||||
**The component architecture is production-ready** with these improvements representing **strategic optimization** rather than critical fixes. The codebase demonstrates **mature Vue.js development practices** with excellent separation of concerns and consistent patterns.
|
||||
|
||||
---
|
||||
|
||||
**Investigation Methodology:**
|
||||
- Static analysis of 291 source files (197 general + 94 Vue components/views)
|
||||
- Pattern recognition across 104,527 lines of code
|
||||
- Manual review of large files and complexity patterns
|
||||
- Dependency analysis and coupling assessment
|
||||
- Performance anti-pattern identification
|
||||
- Architecture consistency evaluation
|
||||
21
README.md
21
README.md
@@ -18,7 +18,7 @@ npm install
|
||||
npm run build:web:serve -- --test
|
||||
```
|
||||
|
||||
To be able to make submissions: go to "profile" (bottom left), go to the bottom and expand "Show Advanced Settings", go to the bottom and to the "Test Page", and finally "Become User 0" to see all the functionality.
|
||||
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||
|
||||
@@ -68,16 +68,16 @@ TimeSafari supports configurable logging levels via the `VITE_LOG_LEVEL` environ
|
||||
|
||||
```bash
|
||||
# Show only errors
|
||||
VITE_LOG_LEVEL=error npm run dev
|
||||
VITE_LOG_LEVEL=error npm run build:web:dev
|
||||
|
||||
# Show warnings and errors
|
||||
VITE_LOG_LEVEL=warn npm run dev
|
||||
VITE_LOG_LEVEL=warn npm run build:web:dev
|
||||
|
||||
# Show info, warnings, and errors (default)
|
||||
VITE_LOG_LEVEL=info npm run dev
|
||||
VITE_LOG_LEVEL=info npm run build:web:dev
|
||||
|
||||
# Show all log levels including debug
|
||||
VITE_LOG_LEVEL=debug npm run dev
|
||||
VITE_LOG_LEVEL=debug npm run build:web:dev
|
||||
```
|
||||
|
||||
### Available Levels
|
||||
@@ -305,6 +305,17 @@ timesafari/
|
||||
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Critical Vue Reactivity Bug
|
||||
A critical Vue reactivity bug was discovered during ActiveDid migration testing where component properties fail to trigger template updates correctly.
|
||||
|
||||
**Impact**: The `newDirectOffersActivityNumber` element in HomeView.vue requires a watcher workaround to render correctly.
|
||||
|
||||
**Status**: Workaround implemented, investigation ongoing.
|
||||
|
||||
**Documentation**: See [Vue Reactivity Bug Report](doc/vue-reactivity-bug-report.md) for details.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
|
||||
|
||||
@@ -31,8 +31,8 @@ android {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 40
|
||||
versionName "1.0.7"
|
||||
versionCode 47
|
||||
versionName "1.1.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -101,6 +101,8 @@ dependencies {
|
||||
implementation project(':capacitor-android')
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
||||
// Gson for JSON parsing in native notification fetcher
|
||||
implementation "com.google.code.gson:gson:2.10.1"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies {
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capawesome-capacitor-file-picker')
|
||||
implementation project(':timesafari-daily-notification-plugin')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:name=".TimeSafariApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -36,6 +37,30 @@
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Daily Notification Plugin Receivers -->
|
||||
<receiver
|
||||
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.timesafari.daily.NOTIFICATION" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name="com.timesafari.dailynotification.BootReceiver"
|
||||
android:directBootAware="true"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter android:priority="1000">
|
||||
<!-- Delivered very early after reboot (before unlock) -->
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<!-- Delivered after the user unlocks / credential-encrypted storage is available -->
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<!-- Delivered after app update; great for rescheduling alarms without reboot -->
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
@@ -45,4 +70,15 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||
|
||||
<!-- Notification permissions -->
|
||||
<!-- POST_NOTIFICATIONS required for Android 13+ (API 33+) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- SCHEDULE_EXACT_ALARM required for Android 12+ (API 31+) to schedule exact alarms -->
|
||||
<!-- Note: On Android 12+, users can grant/deny this permission -->
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<!-- RECEIVE_BOOT_COMPLETED needed to reschedule notifications after device reboot -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
</manifest>
|
||||
|
||||
@@ -34,5 +34,9 @@
|
||||
{
|
||||
"pkg": "@capawesome/capacitor-file-picker",
|
||||
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@timesafari/daily-notification-plugin",
|
||||
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* TimeSafariApplication.java
|
||||
*
|
||||
* Application class for the TimeSafari app.
|
||||
* Registers the native content fetcher for the Daily Notification Plugin.
|
||||
*
|
||||
* @author TimeSafari Team
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package app.timesafari;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import com.timesafari.dailynotification.DailyNotificationPlugin;
|
||||
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||
|
||||
/**
|
||||
* Application class that registers native fetcher for daily notifications
|
||||
*/
|
||||
public class TimeSafariApplication extends Application {
|
||||
|
||||
private static final String TAG = "TimeSafariApplication";
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
// Instrumentation: Log app initialization with process info
|
||||
int pid = android.os.Process.myPid();
|
||||
String processName = getApplicationInfo().processName;
|
||||
Log.i(TAG, String.format(
|
||||
"APP|ON_CREATE ts=%d pid=%d processName=%s",
|
||||
System.currentTimeMillis(),
|
||||
pid,
|
||||
processName
|
||||
));
|
||||
|
||||
Log.i(TAG, "Initializing TimeSafari Application");
|
||||
|
||||
// Create notification channel for daily notifications (required for Android 8.0+)
|
||||
createNotificationChannel();
|
||||
|
||||
// Register native fetcher with application context
|
||||
Context context = getApplicationContext();
|
||||
NativeNotificationContentFetcher nativeFetcher =
|
||||
new TimeSafariNativeFetcher(context);
|
||||
|
||||
// Instrumentation: Log before registration
|
||||
Log.i(TAG, String.format(
|
||||
"FETCHER|REGISTER_START instanceHash=%d ts=%d",
|
||||
nativeFetcher.hashCode(),
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
|
||||
DailyNotificationPlugin.setNativeFetcher(nativeFetcher);
|
||||
|
||||
// Instrumentation: Verify registration succeeded
|
||||
NativeNotificationContentFetcher verified =
|
||||
DailyNotificationPlugin.getNativeFetcherStatic();
|
||||
boolean registered = (verified != null && verified == nativeFetcher);
|
||||
|
||||
Log.i(TAG, String.format(
|
||||
"FETCHER|REGISTERED providerKey=DailyNotificationPlugin instanceHash=%d registered=%s ts=%d",
|
||||
nativeFetcher.hashCode(),
|
||||
registered,
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
|
||||
Log.i(TAG, "Native fetcher registered: " + nativeFetcher.getClass().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification channel for daily notifications
|
||||
* Required for Android 8.0 (API 26) and above
|
||||
*/
|
||||
private void createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationManager notificationManager =
|
||||
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
if (notificationManager == null) {
|
||||
Log.w(TAG, "NotificationManager is null, cannot create channel");
|
||||
return;
|
||||
}
|
||||
|
||||
// Channel ID must match the one used in DailyNotificationWorker
|
||||
String channelId = "timesafari.daily";
|
||||
String channelName = "Daily Notifications";
|
||||
String channelDescription = "Daily notification updates from TimeSafari";
|
||||
|
||||
// Check if channel already exists
|
||||
NotificationChannel existingChannel = notificationManager.getNotificationChannel(channelId);
|
||||
if (existingChannel != null) {
|
||||
Log.d(TAG, "Notification channel already exists: " + channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the channel with high importance (for priority="high" notifications)
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
channel.setDescription(channelDescription);
|
||||
channel.enableVibration(true);
|
||||
channel.setShowBadge(true);
|
||||
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
Log.i(TAG, "Notification channel created: " + channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* TimeSafariNativeFetcher.java
|
||||
*
|
||||
* Implementation of NativeNotificationContentFetcher for the TimeSafari app.
|
||||
* Fetches notification content from the endorser API endpoint.
|
||||
*
|
||||
* @author TimeSafari Team
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package app.timesafari;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
import androidx.annotation.NonNull;
|
||||
import com.timesafari.dailynotification.FetchContext;
|
||||
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||
import com.timesafari.dailynotification.NotificationContent;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Native content fetcher implementation for TimeSafari
|
||||
*
|
||||
* Fetches notification content from the endorser API endpoint.
|
||||
* Uses the same endpoint as the TypeScript code: /api/v2/report/plansLastUpdatedBetween
|
||||
*/
|
||||
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
||||
|
||||
private static final String TAG = "TimeSafariNativeFetcher";
|
||||
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
|
||||
private static final int CONNECT_TIMEOUT_MS = 10000; // 10 seconds
|
||||
private static final int READ_TIMEOUT_MS = 15000; // 15 seconds
|
||||
private static final int MAX_RETRIES = 3; // Maximum number of retry attempts
|
||||
private static final int RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
|
||||
|
||||
// SharedPreferences constants
|
||||
// NOTE: Must match plugin's SharedPreferences name and keys for starred plans
|
||||
// Plugin uses "daily_notification_timesafari" (see DailyNotificationPlugin.updateStarredPlans)
|
||||
private static final String PREFS_NAME = "daily_notification_timesafari";
|
||||
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds"; // Matches plugin key
|
||||
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id"; // For pagination
|
||||
|
||||
private final Gson gson = new Gson();
|
||||
private final Context appContext;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
// Volatile fields for configuration, set via configure() method
|
||||
private volatile String apiBaseUrl;
|
||||
private volatile String activeDid;
|
||||
private volatile String jwtToken; // Pre-generated JWT token from TypeScript (ES256K signed)
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context Application context for SharedPreferences access
|
||||
*/
|
||||
public TimeSafariNativeFetcher(Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Initialized with context");
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the native fetcher with API credentials
|
||||
*
|
||||
* Called by the plugin when configureNativeFetcher() is invoked from TypeScript.
|
||||
* This method stores the configuration for use in background fetches.
|
||||
*
|
||||
* <p><b>Architecture Note:</b> The JWT token is pre-generated in TypeScript using
|
||||
* TimeSafari's {@code accessTokenForBackground()} function (ES256K DID-based signing).
|
||||
* The native fetcher just uses the token directly - no JWT generation needed.</p>
|
||||
*
|
||||
* @param apiBaseUrl Base URL for API server (e.g., "https://api.endorser.ch")
|
||||
* @param activeDid Active DID for authentication (e.g., "did:ethr:0x...")
|
||||
* @param jwtToken Pre-generated JWT token (ES256K signed) from TypeScript
|
||||
*/
|
||||
@Override
|
||||
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
|
||||
// Instrumentation: Log configuration start
|
||||
int pid = android.os.Process.myPid();
|
||||
Log.i(TAG, String.format(
|
||||
"FETCHER|CONFIGURE_START instanceHash=%d pid=%d ts=%d",
|
||||
this.hashCode(),
|
||||
pid,
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.activeDid = activeDid;
|
||||
this.jwtToken = jwtToken;
|
||||
|
||||
// Instrumentation: Log configuration completion
|
||||
boolean configured = (apiBaseUrl != null && activeDid != null && jwtToken != null);
|
||||
Log.i(TAG, String.format(
|
||||
"FETCHER|CONFIGURE_COMPLETE instanceHash=%d configured=%s apiBaseUrl=%s activeDid=%s jwtLength=%d ts=%d",
|
||||
this.hashCode(),
|
||||
configured,
|
||||
apiBaseUrl != null ? apiBaseUrl : "null",
|
||||
activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null",
|
||||
jwtToken != null ? jwtToken.length() : 0,
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
|
||||
// Enhanced logging for JWT diagnostic purposes
|
||||
Log.i(TAG, "TimeSafariNativeFetcher: Configured with API: " + apiBaseUrl);
|
||||
if (activeDid != null) {
|
||||
Log.i(TAG, "TimeSafariNativeFetcher: ActiveDID: " + activeDid.substring(0, Math.min(30, activeDid.length())) +
|
||||
(activeDid.length() > 30 ? "..." : ""));
|
||||
} else {
|
||||
Log.w(TAG, "TimeSafariNativeFetcher: ActiveDID is NULL");
|
||||
}
|
||||
|
||||
if (jwtToken != null) {
|
||||
Log.i(TAG, "TimeSafariNativeFetcher: JWT token received - Length: " + jwtToken.length() + " chars");
|
||||
// Log first and last 10 chars for verification (not full token for security)
|
||||
String tokenPreview = jwtToken.length() > 20
|
||||
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
|
||||
: jwtToken.substring(0, Math.min(jwtToken.length(), 20)) + "...";
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: JWT preview: " + tokenPreview);
|
||||
} else {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL - API calls will fail");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public CompletableFuture<List<NotificationContent>> fetchContent(
|
||||
@NonNull FetchContext context) {
|
||||
|
||||
// Instrumentation: Log fetch start with context
|
||||
int pid = android.os.Process.myPid();
|
||||
Log.i(TAG, String.format(
|
||||
"PREFETCH|START id=%s notifyAt=%d trigger=%s instanceHash=%d pid=%d ts=%d",
|
||||
context.scheduledTime != null ? "daily_" + context.scheduledTime : "unknown",
|
||||
context.scheduledTime != null ? context.scheduledTime : 0,
|
||||
context.trigger,
|
||||
this.hashCode(),
|
||||
pid,
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Fetch triggered - trigger=" + context.trigger +
|
||||
", scheduledTime=" + context.scheduledTime + ", fetchTime=" + context.fetchTime);
|
||||
|
||||
// Start with retry count 0
|
||||
return fetchContentWithRetry(context, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content with retry logic for transient errors
|
||||
*
|
||||
* @param context Fetch context
|
||||
* @param retryCount Current retry attempt (0 for first attempt)
|
||||
* @return Future with notification contents or empty list on failure
|
||||
*/
|
||||
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
|
||||
@NonNull FetchContext context, int retryCount) {
|
||||
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
// Check if configured
|
||||
if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
|
||||
Log.e(TAG, String.format(
|
||||
"PREFETCH|SOURCE from=fallback reason=not_configured apiBaseUrl=%s activeDid=%s jwtToken=%s ts=%d",
|
||||
apiBaseUrl != null ? "set" : "null",
|
||||
activeDid != null ? "set" : "null",
|
||||
jwtToken != null ? "set" : "null",
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Not configured. Call configureNativeFetcher() from TypeScript first.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Instrumentation: Log native fetcher usage
|
||||
Log.i(TAG, String.format(
|
||||
"PREFETCH|SOURCE from=native instanceHash=%d apiBaseUrl=%s ts=%d",
|
||||
this.hashCode(),
|
||||
apiBaseUrl,
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
|
||||
Log.i(TAG, "TimeSafariNativeFetcher: Starting fetch from " + apiBaseUrl + ENDORSER_ENDPOINT);
|
||||
|
||||
// Build request URL
|
||||
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
|
||||
URL url = new URL(urlString);
|
||||
|
||||
// Create HTTP connection
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(READ_TIMEOUT_MS);
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
|
||||
// Diagnostic logging for JWT usage
|
||||
if (jwtToken != null) {
|
||||
String jwtPreview = jwtToken.length() > 20
|
||||
? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10)
|
||||
: jwtToken;
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Using JWT for API call - Length: " + jwtToken.length() +
|
||||
", Preview: " + jwtPreview + ", ActiveDID: " +
|
||||
(activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null"));
|
||||
} else {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: JWT token is NULL when making API call!");
|
||||
}
|
||||
|
||||
connection.setRequestProperty("Authorization", "Bearer " + jwtToken);
|
||||
connection.setDoOutput(true);
|
||||
|
||||
// Build request body
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("planIds", getStarredPlanIds());
|
||||
|
||||
// afterId is required by the API endpoint
|
||||
// Use "0" for first request (no previous data), or stored jwtId for subsequent requests
|
||||
String afterId = getLastAcknowledgedJwtId();
|
||||
if (afterId == null || afterId.isEmpty()) {
|
||||
afterId = "0"; // First request - start from beginning
|
||||
}
|
||||
requestBody.put("afterId", afterId);
|
||||
|
||||
String jsonBody = gson.toJson(requestBody);
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Request body: " + jsonBody);
|
||||
|
||||
// Write request body
|
||||
try (OutputStream os = connection.getOutputStream()) {
|
||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||
os.write(input, 0, input.length);
|
||||
}
|
||||
|
||||
// Execute request
|
||||
int responseCode = connection.getResponseCode();
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: HTTP response code: " + responseCode);
|
||||
|
||||
if (responseCode == 200) {
|
||||
// Read response
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
|
||||
StringBuilder response = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
reader.close();
|
||||
|
||||
String responseBody = response.toString();
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Response body length: " + responseBody.length());
|
||||
|
||||
// Parse response and convert to NotificationContent
|
||||
List<NotificationContent> contents = parseApiResponse(responseBody, context);
|
||||
|
||||
// Update last acknowledged JWT ID from the response (for pagination)
|
||||
if (!contents.isEmpty()) {
|
||||
// Get the last JWT ID from the parsed response (stored during parsing)
|
||||
updateLastAckedJwtIdFromResponse(contents, responseBody);
|
||||
}
|
||||
|
||||
Log.i(TAG, "TimeSafariNativeFetcher: Successfully fetched " + contents.size() +
|
||||
" notification(s)");
|
||||
|
||||
// Instrumentation: Log successful fetch
|
||||
Log.i(TAG, String.format(
|
||||
"PREFETCH|WRITE_OK id=%s items=%d ts=%d",
|
||||
context.scheduledTime != null ? "daily_" + context.scheduledTime : "unknown",
|
||||
contents.size(),
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
|
||||
return contents;
|
||||
|
||||
} else {
|
||||
// Read error response
|
||||
String errorMessage = "Unknown error";
|
||||
try {
|
||||
BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8));
|
||||
StringBuilder errorResponse = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
errorResponse.append(line);
|
||||
}
|
||||
reader.close();
|
||||
errorMessage = errorResponse.toString();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "TimeSafariNativeFetcher: Could not read error stream", e);
|
||||
}
|
||||
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: API error " + responseCode + ": " + errorMessage);
|
||||
|
||||
// Handle retryable errors (5xx server errors, network timeouts)
|
||||
if (shouldRetry(responseCode, retryCount)) {
|
||||
long delayMs = RETRY_DELAY_MS * (1 << retryCount); // Exponential backoff
|
||||
Log.w(TAG, "TimeSafariNativeFetcher: Retryable error, retrying in " + delayMs + "ms " +
|
||||
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
|
||||
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Retry delay interrupted", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Recursive retry
|
||||
return fetchContentWithRetry(context, retryCount + 1).join();
|
||||
}
|
||||
|
||||
// Non-retryable errors (4xx client errors, max retries reached)
|
||||
if (responseCode >= 400 && responseCode < 500) {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Non-retryable client error " + responseCode);
|
||||
} else if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Max retries (" + MAX_RETRIES + ") reached");
|
||||
}
|
||||
|
||||
// Return empty list on error (fallback will be handled by worker)
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException | java.net.UnknownHostException e) {
|
||||
// Network errors are retryable
|
||||
Log.w(TAG, "TimeSafariNativeFetcher: Network error during fetch", e);
|
||||
|
||||
if (shouldRetry(0, retryCount)) { // Use 0 as response code for network errors
|
||||
long delayMs = RETRY_DELAY_MS * (1 << retryCount);
|
||||
Log.w(TAG, "TimeSafariNativeFetcher: Retrying after network error in " + delayMs + "ms " +
|
||||
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
|
||||
|
||||
try {
|
||||
Thread.sleep(delayMs);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Retry delay interrupted", ie);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return fetchContentWithRetry(context, retryCount + 1).join();
|
||||
}
|
||||
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Max retries reached for network error");
|
||||
return Collections.emptyList();
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Error during fetch", e);
|
||||
// Non-retryable errors (parsing, configuration, etc.)
|
||||
return Collections.emptyList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error should be retried
|
||||
*
|
||||
* @param responseCode HTTP response code (0 for network errors)
|
||||
* @param retryCount Current retry attempt count
|
||||
* @return true if error is retryable and retry count not exceeded
|
||||
*/
|
||||
private boolean shouldRetry(int responseCode, int retryCount) {
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
return false; // Max retries exceeded
|
||||
}
|
||||
|
||||
// Retry on network errors (responseCode 0) or server errors (5xx)
|
||||
// Don't retry on client errors (4xx) as they indicate permanent issues
|
||||
if (responseCode == 0) {
|
||||
return true; // Network error (timeout, unknown host, etc.)
|
||||
}
|
||||
|
||||
if (responseCode >= 500 && responseCode < 600) {
|
||||
return true; // Server error (retryable)
|
||||
}
|
||||
|
||||
// Some 4xx errors might be retryable (e.g., 429 Too Many Requests)
|
||||
if (responseCode == 429) {
|
||||
return true; // Rate limit - retry with backoff
|
||||
}
|
||||
|
||||
return false; // Other client errors (401, 403, 404, etc.) are not retryable
|
||||
}
|
||||
|
||||
/**
|
||||
* Get starred plan IDs from SharedPreferences
|
||||
*
|
||||
* @return List of starred plan IDs, empty list if none stored
|
||||
*/
|
||||
private List<String> getStarredPlanIds() {
|
||||
try {
|
||||
// Use the same SharedPreferences as the plugin (not the instance variable 'prefs')
|
||||
// Plugin stores in "daily_notification_timesafari" with key "starredPlanIds"
|
||||
SharedPreferences pluginPrefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
String idsJson = pluginPrefs.getString(KEY_STARRED_PLAN_IDS, "[]");
|
||||
|
||||
if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) {
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: No starred plan IDs found in SharedPreferences");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// Parse JSON array (plugin stores as JSON string)
|
||||
JsonParser parser = new JsonParser();
|
||||
JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray();
|
||||
List<String> planIds = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < jsonArray.size(); i++) {
|
||||
planIds.add(jsonArray.get(i).getAsString());
|
||||
}
|
||||
|
||||
Log.i(TAG, "TimeSafariNativeFetcher: Loaded " + planIds.size() + " starred plan IDs from SharedPreferences");
|
||||
if (planIds.size() > 0) {
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: First plan ID: " +
|
||||
planIds.get(0).substring(0, Math.min(30, planIds.get(0).length())) + "...");
|
||||
}
|
||||
return planIds;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Error loading starred plan IDs from SharedPreferences", e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last acknowledged JWT ID from SharedPreferences (for pagination)
|
||||
*
|
||||
* @return Last acknowledged JWT ID, or null if none stored
|
||||
*/
|
||||
private String getLastAcknowledgedJwtId() {
|
||||
try {
|
||||
String jwtId = prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
|
||||
if (jwtId != null) {
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Loaded last acknowledged JWT ID");
|
||||
}
|
||||
return jwtId;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Error loading last acknowledged JWT ID", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last acknowledged JWT ID from the API response
|
||||
* Uses the last JWT ID from the data array for pagination
|
||||
*
|
||||
* @param contents Parsed notification contents (may contain JWT IDs)
|
||||
* @param responseBody Original response body for parsing
|
||||
*/
|
||||
private void updateLastAckedJwtIdFromResponse(List<NotificationContent> contents, String responseBody) {
|
||||
try {
|
||||
JsonParser parser = new JsonParser();
|
||||
JsonObject root = parser.parse(responseBody).getAsJsonObject();
|
||||
JsonArray dataArray = root.getAsJsonArray("data");
|
||||
|
||||
if (dataArray != null && dataArray.size() > 0) {
|
||||
// Get the last item's JWT ID (most recent)
|
||||
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
|
||||
|
||||
// Try to get JWT ID from different possible locations in response structure
|
||||
String jwtId = null;
|
||||
if (lastItem.has("jwtId")) {
|
||||
jwtId = lastItem.get("jwtId").getAsString();
|
||||
} else if (lastItem.has("plan")) {
|
||||
JsonObject plan = lastItem.getAsJsonObject("plan");
|
||||
if (plan.has("jwtId")) {
|
||||
jwtId = plan.get("jwtId").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
if (jwtId != null && !jwtId.isEmpty()) {
|
||||
updateLastAckedJwtId(jwtId);
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID: " +
|
||||
jwtId.substring(0, Math.min(20, jwtId.length())) + "...");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "TimeSafariNativeFetcher: Could not extract JWT ID from response for pagination", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last acknowledged JWT ID in SharedPreferences
|
||||
*
|
||||
* @param jwtId JWT ID to store as last acknowledged
|
||||
*/
|
||||
private void updateLastAckedJwtId(String jwtId) {
|
||||
try {
|
||||
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
|
||||
Log.d(TAG, "TimeSafariNativeFetcher: Updated last acknowledged JWT ID");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Error updating last acknowledged JWT ID", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse API response and convert to NotificationContent list
|
||||
*/
|
||||
private List<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
|
||||
List<NotificationContent> contents = new ArrayList<>();
|
||||
|
||||
try {
|
||||
JsonParser parser = new JsonParser();
|
||||
JsonObject root = parser.parse(responseBody).getAsJsonObject();
|
||||
|
||||
// Parse response structure (matches PlansLastUpdatedResponse)
|
||||
JsonArray dataArray = root.getAsJsonArray("data");
|
||||
if (dataArray != null) {
|
||||
for (int i = 0; i < dataArray.size(); i++) {
|
||||
JsonObject item = dataArray.get(i).getAsJsonObject();
|
||||
|
||||
NotificationContent content = new NotificationContent();
|
||||
|
||||
// Extract data from API response
|
||||
// Support both flat structure (jwtId, planId) and nested (plan.jwtId, plan.handleId)
|
||||
String planId = null;
|
||||
String jwtId = null;
|
||||
|
||||
if (item.has("planId")) {
|
||||
planId = item.get("planId").getAsString();
|
||||
} else if (item.has("plan")) {
|
||||
JsonObject plan = item.getAsJsonObject("plan");
|
||||
if (plan.has("handleId")) {
|
||||
planId = plan.get("handleId").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
if (item.has("jwtId")) {
|
||||
jwtId = item.get("jwtId").getAsString();
|
||||
} else if (item.has("plan")) {
|
||||
JsonObject plan = item.getAsJsonObject("plan");
|
||||
if (plan.has("jwtId")) {
|
||||
jwtId = plan.get("jwtId").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
// Create notification ID
|
||||
String notificationId = "endorser_" + (jwtId != null ? jwtId :
|
||||
System.currentTimeMillis() + "_" + i);
|
||||
content.setId(notificationId);
|
||||
|
||||
// Create notification title
|
||||
String title = "Project Update";
|
||||
if (planId != null) {
|
||||
title = "Update: " + planId.substring(Math.max(0, planId.length() - 8));
|
||||
}
|
||||
content.setTitle(title);
|
||||
|
||||
// Create notification body
|
||||
StringBuilder body = new StringBuilder();
|
||||
if (planId != null) {
|
||||
body.append("Plan ").append(planId.substring(Math.max(0, planId.length() - 12))).append(" has been updated.");
|
||||
} else {
|
||||
body.append("A project you follow has been updated.");
|
||||
}
|
||||
content.setBody(body.toString());
|
||||
|
||||
// Use scheduled time from context, or default to 1 hour from now
|
||||
long scheduledTimeMs = context.scheduledTime != null ?
|
||||
context.scheduledTime : (System.currentTimeMillis() + 3600000);
|
||||
content.setScheduledTime(scheduledTimeMs);
|
||||
|
||||
// Set notification properties
|
||||
content.setPriority("default");
|
||||
content.setSound(true);
|
||||
|
||||
contents.add(content);
|
||||
}
|
||||
}
|
||||
|
||||
// If no data items, create a default notification
|
||||
if (contents.isEmpty()) {
|
||||
NotificationContent defaultContent = new NotificationContent();
|
||||
defaultContent.setId("endorser_no_updates_" + System.currentTimeMillis());
|
||||
defaultContent.setTitle("No Project Updates");
|
||||
defaultContent.setBody("No updates found in your starred projects.");
|
||||
|
||||
long scheduledTimeMs = context.scheduledTime != null ?
|
||||
context.scheduledTime : (System.currentTimeMillis() + 3600000);
|
||||
defaultContent.setScheduledTime(scheduledTimeMs);
|
||||
defaultContent.setPriority("default");
|
||||
defaultContent.setSound(true);
|
||||
|
||||
contents.add(defaultContent);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "TimeSafariNativeFetcher: Error parsing API response", e);
|
||||
// Return empty list on parse error
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,6 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
|
||||
|
||||
include ':capawesome-capacitor-file-picker'
|
||||
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
||||
|
||||
include ':timesafari-daily-notification-plugin'
|
||||
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')
|
||||
|
||||
655
doc/android-emulator-deployment-guide.md
Normal file
655
doc/android-emulator-deployment-guide.md
Normal file
@@ -0,0 +1,655 @@
|
||||
# Android Emulator Deployment Guide (No Android Studio)
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Date**: 2025-01-27
|
||||
**Status**: 🎯 **ACTIVE** - Complete guide for deploying TimeSafari to Android emulator using command-line tools
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides comprehensive instructions for building and deploying TimeSafari to Android emulators using only command-line tools, without requiring Android Studio. It leverages the existing build system and adds emulator-specific deployment workflows.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Tools
|
||||
|
||||
1. **Android SDK Command Line Tools**
|
||||
```bash
|
||||
# Install via package manager (Arch Linux)
|
||||
sudo pacman -S android-sdk-cmdline-tools-latest
|
||||
|
||||
# Or download from Google
|
||||
# https://developer.android.com/studio/command-line
|
||||
```
|
||||
|
||||
2. **Android SDK Platform Tools**
|
||||
```bash
|
||||
# Install via package manager
|
||||
sudo pacman -S android-sdk-platform-tools
|
||||
|
||||
# Or via Android SDK Manager
|
||||
sdkmanager "platform-tools"
|
||||
```
|
||||
|
||||
3. **Android SDK Build Tools**
|
||||
```bash
|
||||
sdkmanager "build-tools;34.0.0"
|
||||
```
|
||||
|
||||
4. **Android Platform**
|
||||
```bash
|
||||
sdkmanager "platforms;android-34"
|
||||
```
|
||||
|
||||
5. **Android Emulator**
|
||||
```bash
|
||||
sdkmanager "emulator"
|
||||
```
|
||||
|
||||
6. **System Images**
|
||||
```bash
|
||||
# For API 34 (Android 14)
|
||||
sdkmanager "system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# For API 33 (Android 13) - alternative
|
||||
sdkmanager "system-images;android-33;google_apis;x86_64"
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc or ~/.zshrc
|
||||
export ANDROID_HOME=$HOME/Android/Sdk
|
||||
export ANDROID_AVD_HOME=$HOME/.android/avd # Important for AVD location
|
||||
export PATH=$PATH:$ANDROID_HOME/emulator
|
||||
export PATH=$PATH:$ANDROID_HOME/platform-tools
|
||||
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
|
||||
export PATH=$PATH:$ANDROID_HOME/build-tools/34.0.0
|
||||
|
||||
# Reload shell
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
# Check all tools are available
|
||||
adb version
|
||||
emulator -version
|
||||
avdmanager list
|
||||
```
|
||||
|
||||
## Resource-Aware Emulator Setup
|
||||
|
||||
### ⚡ **Quick Start Recommendation**
|
||||
|
||||
**For best results, always start with resource analysis:**
|
||||
|
||||
```bash
|
||||
# 1. Check your system capabilities
|
||||
./scripts/avd-resource-checker.sh
|
||||
|
||||
# 2. Use the generated optimal startup script
|
||||
/tmp/start-avd-TimeSafari_Emulator.sh
|
||||
|
||||
# 3. Deploy your app
|
||||
npm run build:android:dev
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
This prevents system lockups and ensures optimal performance.
|
||||
|
||||
### AVD Resource Checker Script
|
||||
|
||||
**New Feature**: TimeSafari includes an intelligent resource checker that automatically detects your system capabilities and recommends optimal AVD configurations.
|
||||
|
||||
```bash
|
||||
# Check system resources and get recommendations
|
||||
./scripts/avd-resource-checker.sh
|
||||
|
||||
# Check resources for specific AVD
|
||||
./scripts/avd-resource-checker.sh TimeSafari_Emulator
|
||||
|
||||
# Test AVD startup performance
|
||||
./scripts/avd-resource-checker.sh TimeSafari_Emulator --test
|
||||
|
||||
# Create optimized AVD with recommended settings
|
||||
./scripts/avd-resource-checker.sh TimeSafari_Emulator --create
|
||||
```
|
||||
|
||||
**What the script analyzes:**
|
||||
- **System Memory**: Total and available RAM
|
||||
- **CPU Cores**: Available processing power
|
||||
- **GPU Capabilities**: NVIDIA, AMD, Intel, or software rendering
|
||||
- **Hardware Acceleration**: Optimal graphics settings
|
||||
|
||||
**What it generates:**
|
||||
- **Optimal configuration**: Memory, cores, and GPU settings
|
||||
- **Startup command**: Ready-to-use emulator command
|
||||
- **Startup script**: Saved to `/tmp/start-avd-{name}.sh` for reuse
|
||||
|
||||
## Emulator Management
|
||||
|
||||
### Create Android Virtual Device (AVD)
|
||||
|
||||
```bash
|
||||
# List available system images
|
||||
avdmanager list target
|
||||
|
||||
# Create AVD for API 34
|
||||
avdmanager create avd \
|
||||
--name "TimeSafari_Emulator" \
|
||||
--package "system-images;android-34;google_apis;x86_64" \
|
||||
--device "pixel_7"
|
||||
|
||||
# List created AVDs
|
||||
avdmanager list avd
|
||||
```
|
||||
|
||||
### Start Emulator
|
||||
|
||||
```bash
|
||||
# Start emulator with hardware acceleration (recommended)
|
||||
emulator -avd TimeSafari_Emulator -gpu host -no-audio &
|
||||
|
||||
# Start with reduced resources (if system has limited RAM)
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-memory 2048 \
|
||||
-cores 2 \
|
||||
-gpu swiftshader_indirect &
|
||||
|
||||
# Start with minimal resources (safest for low-end systems)
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-memory 1536 \
|
||||
-cores 1 \
|
||||
-gpu swiftshader_indirect &
|
||||
|
||||
# Check if emulator is running
|
||||
adb devices
|
||||
```
|
||||
|
||||
### Resource Management
|
||||
|
||||
**Important**: Android emulators can consume significant system resources. Choose the appropriate configuration based on your system:
|
||||
|
||||
- **High-end systems** (16GB+ RAM, dedicated GPU): Use `-gpu host`
|
||||
- **Mid-range systems** (8-16GB RAM): Use `-memory 2048 -cores 2`
|
||||
- **Low-end systems** (4-8GB RAM): Use `-memory 1536 -cores 1 -gpu swiftshader_indirect`
|
||||
|
||||
### Emulator Control
|
||||
|
||||
```bash
|
||||
# Stop emulator
|
||||
adb emu kill
|
||||
|
||||
# Restart emulator
|
||||
adb reboot
|
||||
|
||||
# Check emulator status
|
||||
adb get-state
|
||||
```
|
||||
|
||||
## Build and Deploy Workflow
|
||||
|
||||
### Method 1: Using Existing Build Scripts
|
||||
|
||||
The TimeSafari project already has comprehensive Android build scripts that can be adapted for emulator deployment:
|
||||
|
||||
```bash
|
||||
# Development build with auto-run
|
||||
npm run build:android:dev:run
|
||||
|
||||
# Test build with auto-run
|
||||
npm run build:android:test:run
|
||||
|
||||
# Production build with auto-run
|
||||
npm run build:android:prod:run
|
||||
```
|
||||
|
||||
### Method 2: Custom Emulator Deployment Script
|
||||
|
||||
Create a new script specifically for emulator deployment:
|
||||
|
||||
```bash
|
||||
# Create emulator deployment script
|
||||
cat > scripts/deploy-android-emulator.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# deploy-android-emulator.sh
|
||||
# Author: Matthew Raymer
|
||||
# Date: 2025-01-27
|
||||
# Description: Deploy TimeSafari to Android emulator without Android Studio
|
||||
|
||||
set -e
|
||||
|
||||
# Source common utilities
|
||||
source "$(dirname "$0")/common.sh"
|
||||
|
||||
# Default values
|
||||
BUILD_MODE="development"
|
||||
AVD_NAME="TimeSafari_Emulator"
|
||||
START_EMULATOR=true
|
||||
CLEAN_BUILD=true
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dev|--development)
|
||||
BUILD_MODE="development"
|
||||
shift
|
||||
;;
|
||||
--test)
|
||||
BUILD_MODE="test"
|
||||
shift
|
||||
;;
|
||||
--prod|--production)
|
||||
BUILD_MODE="production"
|
||||
shift
|
||||
;;
|
||||
--avd)
|
||||
AVD_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-start-emulator)
|
||||
START_EMULATOR=false
|
||||
shift
|
||||
;;
|
||||
--no-clean)
|
||||
CLEAN_BUILD=false
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [options]"
|
||||
echo "Options:"
|
||||
echo " --dev, --development Build for development"
|
||||
echo " --test Build for testing"
|
||||
echo " --prod, --production Build for production"
|
||||
echo " --avd NAME Use specific AVD name"
|
||||
echo " --no-start-emulator Don't start emulator"
|
||||
echo " --no-clean Skip clean build"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Function to check if emulator is running
|
||||
check_emulator_running() {
|
||||
if adb devices | grep -q "emulator.*device"; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to start emulator
|
||||
start_emulator() {
|
||||
log_info "Starting Android emulator: $AVD_NAME"
|
||||
|
||||
# Check if AVD exists
|
||||
if ! avdmanager list avd | grep -q "$AVD_NAME"; then
|
||||
log_error "AVD '$AVD_NAME' not found. Please create it first."
|
||||
log_info "Create AVD with: avdmanager create avd --name $AVD_NAME --package system-images;android-34;google_apis;x86_64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start emulator in background
|
||||
emulator -avd "$AVD_NAME" -no-audio -no-snapshot &
|
||||
EMULATOR_PID=$!
|
||||
|
||||
# Wait for emulator to boot
|
||||
log_info "Waiting for emulator to boot..."
|
||||
adb wait-for-device
|
||||
|
||||
# Wait for boot to complete
|
||||
log_info "Waiting for boot to complete..."
|
||||
while [ "$(adb shell getprop sys.boot_completed)" != "1" ]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log_success "Emulator is ready!"
|
||||
}
|
||||
|
||||
# Function to build and deploy
|
||||
build_and_deploy() {
|
||||
log_info "Building TimeSafari for $BUILD_MODE mode..."
|
||||
|
||||
# Clean build if requested
|
||||
if [ "$CLEAN_BUILD" = true ]; then
|
||||
log_info "Cleaning previous build..."
|
||||
npm run clean:android
|
||||
fi
|
||||
|
||||
# Build based on mode
|
||||
case $BUILD_MODE in
|
||||
"development")
|
||||
npm run build:android:dev
|
||||
;;
|
||||
"test")
|
||||
npm run build:android:test
|
||||
;;
|
||||
"production")
|
||||
npm run build:android:prod
|
||||
;;
|
||||
esac
|
||||
|
||||
# Deploy to emulator
|
||||
log_info "Deploying to emulator..."
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# Launch app
|
||||
log_info "Launching TimeSafari..."
|
||||
adb shell am start -n app.timesafari/.MainActivity
|
||||
|
||||
log_success "TimeSafari deployed and launched successfully!"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
log_info "TimeSafari Android Emulator Deployment"
|
||||
log_info "Build Mode: $BUILD_MODE"
|
||||
log_info "AVD Name: $AVD_NAME"
|
||||
|
||||
# Start emulator if requested and not running
|
||||
if [ "$START_EMULATOR" = true ]; then
|
||||
if ! check_emulator_running; then
|
||||
start_emulator
|
||||
else
|
||||
log_info "Emulator already running"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build and deploy
|
||||
build_and_deploy
|
||||
|
||||
log_success "Deployment completed successfully!"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
EOF
|
||||
|
||||
# Make script executable
|
||||
chmod +x scripts/deploy-android-emulator.sh
|
||||
```
|
||||
|
||||
### Method 3: Direct Command Line Deployment
|
||||
|
||||
For quick deployments without scripts:
|
||||
|
||||
```bash
|
||||
# 1. Ensure emulator is running
|
||||
adb devices
|
||||
|
||||
# 2. Build the app
|
||||
npm run build:android:dev
|
||||
|
||||
# 3. Install APK
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# 4. Launch app
|
||||
adb shell am start -n app.timesafari/.MainActivity
|
||||
|
||||
# 5. View logs
|
||||
adb logcat | grep -E "(TimeSafari|Capacitor|MainActivity)"
|
||||
```
|
||||
|
||||
## Advanced Deployment Options
|
||||
|
||||
### Custom API Server Configuration
|
||||
|
||||
For development with custom API endpoints:
|
||||
|
||||
```bash
|
||||
# Build with custom API IP
|
||||
npm run build:android:dev:custom
|
||||
|
||||
# Or modify capacitor.config.ts for specific IP
|
||||
# Then build normally
|
||||
npm run build:android:dev
|
||||
```
|
||||
|
||||
### Debug vs Release Builds
|
||||
|
||||
```bash
|
||||
# Debug build (default)
|
||||
npm run build:android:debug
|
||||
|
||||
# Release build
|
||||
npm run build:android:release
|
||||
|
||||
# Install specific build
|
||||
adb install -r android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
### Asset Management
|
||||
|
||||
```bash
|
||||
# Validate Android assets
|
||||
npm run assets:validate:android
|
||||
|
||||
# Generate assets only
|
||||
npm run build:android:assets
|
||||
|
||||
# Clean assets
|
||||
npm run assets:clean
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Emulator Not Starting / AVD Not Found**
|
||||
```bash
|
||||
# Check available AVDs
|
||||
avdmanager list avd
|
||||
|
||||
# If AVD exists but emulator can't find it, check AVD location
|
||||
echo $ANDROID_AVD_HOME
|
||||
ls -la ~/.android/avd/
|
||||
|
||||
# Fix AVD path issue (common on Arch Linux)
|
||||
export ANDROID_AVD_HOME=/home/$USER/.config/.android/avd
|
||||
|
||||
# Or create symlinks if AVDs are in different location
|
||||
mkdir -p ~/.android/avd
|
||||
ln -s /home/$USER/.config/.android/avd/* ~/.android/avd/
|
||||
|
||||
# Create new AVD if needed
|
||||
avdmanager create avd --name "TimeSafari_Emulator" --package "system-images;android-34;google_apis;x86_64"
|
||||
|
||||
# Check emulator logs
|
||||
emulator -avd TimeSafari_Emulator -verbose
|
||||
```
|
||||
|
||||
2. **System Lockup / High Resource Usage**
|
||||
```bash
|
||||
# Kill any stuck emulator processes
|
||||
pkill -f emulator
|
||||
|
||||
# Check system resources
|
||||
free -h
|
||||
nvidia-smi # if using NVIDIA GPU
|
||||
|
||||
# Start with minimal resources
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-memory 1536 \
|
||||
-cores 1 \
|
||||
-gpu swiftshader_indirect &
|
||||
|
||||
# Monitor resource usage
|
||||
htop
|
||||
|
||||
# If still having issues, try software rendering only
|
||||
emulator -avd TimeSafari_Emulator \
|
||||
-no-audio \
|
||||
-no-snapshot \
|
||||
-memory 1024 \
|
||||
-cores 1 \
|
||||
-gpu off &
|
||||
```
|
||||
|
||||
3. **ADB Device Not Found**
|
||||
```bash
|
||||
# Restart ADB server
|
||||
adb kill-server
|
||||
adb start-server
|
||||
|
||||
# Check devices
|
||||
adb devices
|
||||
|
||||
# Check emulator status
|
||||
adb get-state
|
||||
```
|
||||
|
||||
3. **Build Failures**
|
||||
```bash
|
||||
# Clean everything
|
||||
npm run clean:android
|
||||
|
||||
# Rebuild
|
||||
npm run build:android:dev
|
||||
|
||||
# Check Gradle logs
|
||||
cd android && ./gradlew clean --stacktrace
|
||||
```
|
||||
|
||||
4. **Installation Failures**
|
||||
```bash
|
||||
# Uninstall existing app
|
||||
adb uninstall app.timesafari
|
||||
|
||||
# Reinstall
|
||||
adb install android/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# Check package info
|
||||
adb shell pm list packages | grep timesafari
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
1. **Emulator Performance**
|
||||
```bash
|
||||
# Start with hardware acceleration
|
||||
emulator -avd TimeSafari_Emulator -gpu host
|
||||
|
||||
# Use snapshot for faster startup
|
||||
emulator -avd TimeSafari_Emulator -snapshot default
|
||||
|
||||
# Allocate more RAM
|
||||
emulator -avd TimeSafari_Emulator -memory 4096
|
||||
```
|
||||
|
||||
2. **Build Performance**
|
||||
```bash
|
||||
# Use Gradle daemon
|
||||
echo "org.gradle.daemon=true" >> android/gradle.properties
|
||||
|
||||
# Increase heap size
|
||||
echo "org.gradle.jvmargs=-Xmx4g" >> android/gradle.properties
|
||||
|
||||
# Enable parallel builds
|
||||
echo "org.gradle.parallel=true" >> android/gradle.properties
|
||||
```
|
||||
|
||||
## Integration with Existing Build System
|
||||
|
||||
### NPM Scripts Integration
|
||||
|
||||
Add emulator-specific scripts to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"emulator:check": "./scripts/avd-resource-checker.sh",
|
||||
"emulator:check:test": "./scripts/avd-resource-checker.sh TimeSafari_Emulator --test",
|
||||
"emulator:check:create": "./scripts/avd-resource-checker.sh TimeSafari_Emulator --create",
|
||||
"emulator:start": "emulator -avd TimeSafari_Emulator -no-audio &",
|
||||
"emulator:start:optimized": "/tmp/start-avd-TimeSafari_Emulator.sh",
|
||||
"emulator:stop": "adb emu kill",
|
||||
"emulator:deploy": "./scripts/deploy-android-emulator.sh",
|
||||
"emulator:deploy:dev": "./scripts/deploy-android-emulator.sh --dev",
|
||||
"emulator:deploy:test": "./scripts/deploy-android-emulator.sh --test",
|
||||
"emulator:deploy:prod": "./scripts/deploy-android-emulator.sh --prod",
|
||||
"emulator:logs": "adb logcat | grep -E '(TimeSafari|Capacitor|MainActivity)'",
|
||||
"emulator:shell": "adb shell"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
For automated testing and deployment:
|
||||
|
||||
```bash
|
||||
# GitHub Actions example
|
||||
- name: Start Android Emulator
|
||||
run: |
|
||||
emulator -avd TimeSafari_Emulator -no-audio -no-snapshot &
|
||||
adb wait-for-device
|
||||
adb shell getprop sys.boot_completed
|
||||
|
||||
- name: Build and Deploy
|
||||
run: |
|
||||
npm run build:android:test
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
adb shell am start -n app.timesafari/.MainActivity
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
npm run test:android
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Start emulator once per session**
|
||||
```bash
|
||||
emulator -avd TimeSafari_Emulator -no-audio &
|
||||
```
|
||||
|
||||
2. **Use incremental builds**
|
||||
```bash
|
||||
# For rapid iteration
|
||||
npm run build:android:sync
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
3. **Monitor logs continuously**
|
||||
```bash
|
||||
adb logcat | grep -E "(TimeSafari|Capacitor|MainActivity)" --color=always
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. **Use snapshots for faster startup**
|
||||
2. **Enable hardware acceleration**
|
||||
3. **Allocate sufficient RAM (4GB+)**
|
||||
4. **Use SSD storage for AVDs**
|
||||
5. **Close unnecessary applications**
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **Use debug builds for development only**
|
||||
2. **Never commit debug keystores**
|
||||
3. **Use release builds for testing**
|
||||
4. **Validate API endpoints in production builds**
|
||||
|
||||
## Conclusion
|
||||
|
||||
This guide provides a complete solution for deploying TimeSafari to Android emulators without Android Studio. The approach leverages the existing build system while adding emulator-specific deployment capabilities.
|
||||
|
||||
The key benefits:
|
||||
- ✅ **No Android Studio required**
|
||||
- ✅ **Command-line only workflow**
|
||||
- ✅ **Integration with existing build scripts**
|
||||
- ✅ **Automated deployment options**
|
||||
- ✅ **Comprehensive troubleshooting guide**
|
||||
|
||||
For questions or issues, refer to the troubleshooting section or check the existing build documentation in `BUILDING.md`.
|
||||
1146
doc/daily-notification-plugin-integration-plan.md
Normal file
1146
doc/daily-notification-plugin-integration-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -60,13 +60,49 @@ For complex tasks, you might combine multiple meta-rules:
|
||||
meta_core_always_on + meta_research + meta_bug_diagnosis
|
||||
```
|
||||
|
||||
## Workflow Flexibility: Phase-Based, Not Waterfall
|
||||
|
||||
**Important**: Meta-rules represent **workflow phases**, not a rigid sequence. You can:
|
||||
|
||||
### **Jump Between Phases Freely**
|
||||
- **Start with diagnosis** if you already know the problem
|
||||
- **Go back to research** if your fix reveals new issues
|
||||
- **Switch to planning** mid-implementation if scope changes
|
||||
- **Document at any phase** - not just at the end
|
||||
|
||||
### **Mode Switching by Invoking Meta-Rules**
|
||||
Each meta-rule invocation **automatically switches your workflow mode**:
|
||||
|
||||
```
|
||||
Research Mode → Invoke @meta_bug_diagnosis → Diagnosis Mode
|
||||
Diagnosis Mode → Invoke @meta_bug_fixing → Fixing Mode
|
||||
Planning Mode → Invoke @meta_feature_implementation → Implementation Mode
|
||||
```
|
||||
|
||||
### **Phase Constraints, Not Sequence Constraints**
|
||||
- **Within each phase**: Clear constraints on what you can/cannot do
|
||||
- **Between phases**: Complete freedom to move as needed
|
||||
- **No forced order**: Choose the phase that matches your current need
|
||||
|
||||
### **Example of Flexible Workflow**
|
||||
```
|
||||
1. Start with @meta_research (investigation mode)
|
||||
2. Jump to @meta_bug_diagnosis (diagnosis mode)
|
||||
3. Realize you need more research → back to @meta_research
|
||||
4. Complete diagnosis → @meta_bug_fixing (implementation mode)
|
||||
5. Find new issues → back to @meta_bug_diagnosis
|
||||
6. Complete fix → @meta_documentation (documentation mode)
|
||||
```
|
||||
|
||||
**The "sticky" part means**: Each phase has clear boundaries, but you control when to enter/exit phases.
|
||||
|
||||
## Practical Usage Examples
|
||||
|
||||
### **Example 1: Bug Investigation**
|
||||
### **Example 1: Bug Investigation (Flexible Flow)**
|
||||
|
||||
**Scenario**: User reports that the contact list isn't loading properly
|
||||
|
||||
**Meta-Rule Selection**:
|
||||
**Initial Meta-Rule Selection**:
|
||||
```
|
||||
meta_core_always_on + meta_research + meta_bug_diagnosis
|
||||
```
|
||||
@@ -76,13 +112,15 @@ meta_core_always_on + meta_research + meta_bug_diagnosis
|
||||
- **Research**: Systematic investigation methodology, evidence collection
|
||||
- **Bug Diagnosis**: Defect analysis framework, root cause identification
|
||||
|
||||
**Workflow**:
|
||||
**Flexible Workflow**:
|
||||
1. Apply core always-on for foundation
|
||||
2. Use research meta-rule for systematic investigation
|
||||
3. Apply bug diagnosis for defect analysis
|
||||
4. Follow the bundled workflow automatically
|
||||
3. Switch to bug diagnosis when you have enough evidence
|
||||
4. **Can go back to research** if diagnosis reveals new questions
|
||||
5. **Can jump to bug fixing** if root cause is obvious
|
||||
6. **Can document findings** at any phase
|
||||
|
||||
### **Example 2: Feature Development**
|
||||
### **Example 2: Feature Development (Iterative Flow)**
|
||||
|
||||
**Scenario**: Building a new contact search feature
|
||||
|
||||
@@ -96,12 +134,15 @@ meta_core_always_on + meta_feature_planning + meta_feature_implementation
|
||||
- **Feature Planning**: Requirements analysis, architecture planning
|
||||
- **Feature Implementation**: Development workflow, testing strategy
|
||||
|
||||
**Workflow**:
|
||||
**Iterative Workflow**:
|
||||
1. Start with core always-on
|
||||
2. Use feature planning for design and requirements
|
||||
3. Switch to feature implementation for coding and testing
|
||||
4. **Can return to planning** if implementation reveals design issues
|
||||
5. **Can go back to research** if you need to investigate alternatives
|
||||
6. **Can document progress** throughout the process
|
||||
|
||||
### **Example 3: Documentation Creation**
|
||||
### **Example 3: Documentation Creation (Parallel Flow)**
|
||||
|
||||
**Scenario**: Writing a migration guide for the new database system
|
||||
|
||||
@@ -114,10 +155,13 @@ meta_core_always_on + meta_documentation
|
||||
- **Core Always-On**: Foundation and context
|
||||
- **Documentation**: Educational focus, templates, quality standards
|
||||
|
||||
**Workflow**:
|
||||
**Parallel Workflow**:
|
||||
1. Apply core always-on for foundation
|
||||
2. Use documentation meta-rule for educational content creation
|
||||
3. Follow educational templates and quality standards
|
||||
3. **Can research** while documenting if you need more information
|
||||
4. **Can plan** documentation structure as you write
|
||||
5. **Can implement** examples or code snippets as needed
|
||||
6. Follow educational templates and quality standards
|
||||
|
||||
## Meta-Rule Application Process
|
||||
|
||||
|
||||
181
doc/seed-phrase-reminder-implementation.md
Normal file
181
doc/seed-phrase-reminder-implementation.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Seed Phrase Backup Reminder Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds a modal dialog that reminds users to back up their seed phrase if they haven't done so yet. The reminder appears after specific user actions and includes a 24-hour cooldown to avoid being too intrusive.
|
||||
|
||||
## Features
|
||||
|
||||
- **Modal Dialog**: Uses the existing notification group modal system from `App.vue`
|
||||
- **Smart Timing**: Only shows when `hasBackedUpSeed = false`
|
||||
- **24-Hour Cooldown**: Uses localStorage to prevent showing more than once per day
|
||||
- **Action-Based Triggers**: Shows after specific user actions
|
||||
- **User Choice**: "Backup Identifier Seed" or "Remind me Later" options
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Core Utility (`src/utils/seedPhraseReminder.ts`)
|
||||
|
||||
The main utility provides:
|
||||
|
||||
- `shouldShowSeedReminder(hasBackedUpSeed)`: Checks if reminder should be shown
|
||||
- `markSeedReminderShown()`: Updates localStorage timestamp
|
||||
- `createSeedReminderNotification()`: Creates the modal configuration
|
||||
- `showSeedPhraseReminder(hasBackedUpSeed, notifyFunction)`: Main function to show reminder
|
||||
|
||||
### Trigger Points
|
||||
|
||||
The reminder is shown after these user actions:
|
||||
|
||||
**Note**: The reminder is triggered by **claim creation** actions, not claim confirmations. This focuses on when users are actively creating new content rather than just confirming existing claims.
|
||||
|
||||
1. **Profile Saving** (`AccountViewView.vue`)
|
||||
- After clicking "Save Profile" button
|
||||
- Only when profile save is successful
|
||||
|
||||
2. **Claim Creation** (Multiple views)
|
||||
- `ClaimAddRawView.vue`: After submitting raw claims
|
||||
- `GiftedDialog.vue`: After creating gifts/claims
|
||||
- `GiftedDetailsView.vue`: After recording gifts/claims
|
||||
- `OfferDialog.vue`: After creating offers
|
||||
|
||||
3. **QR Code Views Exit**
|
||||
- `ContactQRScanFullView.vue`: When exiting via back button
|
||||
- `ContactQRScanShowView.vue`: When exiting via back button
|
||||
|
||||
### Modal Configuration
|
||||
|
||||
```typescript
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Backup Your Identifier Seed?",
|
||||
text: "It looks like you haven't backed up your identifier seed yet. It's important to back it up as soon as possible to secure your identity.",
|
||||
yesText: "Backup Identifier Seed",
|
||||
noText: "Remind me Later",
|
||||
onYes: () => navigate to /seed-backup,
|
||||
onNo: () => mark as shown for 24 hours,
|
||||
onCancel: () => mark as shown for 24 hours
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The modal is configured with `timeout: -1` to ensure it stays open until the user explicitly interacts with one of the buttons. This prevents the dialog from closing automatically.
|
||||
|
||||
### Cooldown Mechanism
|
||||
|
||||
- **Storage Key**: `seedPhraseReminderLastShown`
|
||||
- **Cooldown Period**: 24 hours (24 * 60 * 60 * 1000 milliseconds)
|
||||
- **Implementation**: localStorage with timestamp comparison
|
||||
- **Fallback**: Shows reminder if timestamp is invalid or missing
|
||||
|
||||
## User Experience
|
||||
|
||||
### When Reminder Appears
|
||||
|
||||
- User has not backed up their seed phrase (`hasBackedUpSeed = false`)
|
||||
- At least 24 hours have passed since last reminder
|
||||
- User performs one of the trigger actions
|
||||
- **1-second delay** after the success message to allow users to see the confirmation
|
||||
|
||||
### User Options
|
||||
|
||||
1. **"Backup Identifier Seed"**: Navigates to `/seed-backup` page
|
||||
2. **"Remind me Later"**: Dismisses and won't show again for 24 hours
|
||||
3. **Cancel/Close**: Same behavior as "Remind me Later"
|
||||
|
||||
### Frequency Control
|
||||
|
||||
- **First Time**: Always shows if user hasn't backed up
|
||||
- **Subsequent**: Only shows after 24-hour cooldown
|
||||
- **Automatic Reset**: When user completes seed backup (`hasBackedUpSeed = true`)
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Graceful fallback if localStorage operations fail
|
||||
- Logging of errors for debugging
|
||||
- Non-blocking implementation (doesn't affect main functionality)
|
||||
|
||||
### Integration Points
|
||||
|
||||
- **Platform Service**: Uses `$accountSettings()` to check backup status
|
||||
- **Notification System**: Integrates with existing `$notify` system
|
||||
- **Router**: Uses `window.location.href` for navigation
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Minimal localStorage operations
|
||||
- No blocking operations
|
||||
- Efficient timestamp comparisons
|
||||
- **Timing Behavior**: 1-second delay before showing reminder to improve user experience flow
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Scenarios
|
||||
|
||||
1. **First Time User**
|
||||
- Create new account
|
||||
- Perform trigger action (save profile, create claim, exit QR view)
|
||||
- Verify reminder appears
|
||||
|
||||
2. **Repeat User (Within 24h)**
|
||||
- Perform trigger action
|
||||
- Verify reminder does NOT appear
|
||||
|
||||
3. **Repeat User (After 24h)**
|
||||
- Wait 24+ hours
|
||||
- Perform trigger action
|
||||
- Verify reminder appears again
|
||||
|
||||
4. **User Who Has Backed Up**
|
||||
- Complete seed backup
|
||||
- Perform trigger action
|
||||
- Verify reminder does NOT appear
|
||||
|
||||
5. **QR Code View Exit**
|
||||
- Navigate to QR code view (full or show)
|
||||
- Exit via back button
|
||||
- Verify reminder appears (if conditions are met)
|
||||
|
||||
### Browser Testing
|
||||
|
||||
- Test localStorage functionality
|
||||
- Verify timestamp handling
|
||||
- Check navigation to seed backup page
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
|
||||
1. **Customizable Cooldown**: Allow users to set reminder frequency
|
||||
2. **Progressive Urgency**: Increase reminder frequency over time
|
||||
3. **Analytics**: Track reminder effectiveness and user response
|
||||
4. **A/B Testing**: Test different reminder messages and timing
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- Reminder frequency settings
|
||||
- Custom reminder messages
|
||||
- Different trigger conditions
|
||||
- Integration with other notification systems
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Monitoring
|
||||
|
||||
- Check localStorage usage in browser dev tools
|
||||
- Monitor user feedback about reminder frequency
|
||||
- Track navigation success to seed backup page
|
||||
|
||||
### Updates
|
||||
|
||||
- Modify reminder text in `createSeedReminderNotification()`
|
||||
- Adjust cooldown period in `REMINDER_COOLDOWN_MS` constant
|
||||
- Add new trigger points as needed
|
||||
|
||||
## Conclusion
|
||||
|
||||
This implementation provides a non-intrusive way to remind users about seed phrase backup while respecting their preferences and avoiding notification fatigue. The 24-hour cooldown ensures users aren't overwhelmed while maintaining the importance of the security reminder.
|
||||
|
||||
The feature is fully integrated with the existing codebase architecture and follows established patterns for notifications, error handling, and user interaction.
|
||||
109
docs/directives/fix-notification-dismiss-cancel.mdc
Normal file
109
docs/directives/fix-notification-dismiss-cancel.mdc
Normal file
@@ -0,0 +1,109 @@
|
||||
# Fix Notification Dismiss to Cancel Notification
|
||||
|
||||
## Problem
|
||||
|
||||
When a user clicks the "Dismiss" button on a daily notification, the notification is removed from storage and alarms are cancelled, but the notification itself is not cancelled from the NotificationManager. This means the notification remains visible in the system tray even though it's been dismissed.
|
||||
|
||||
Additionally, clicking on the notification (not the dismiss button) launches the app, which is working as intended.
|
||||
|
||||
## Root Cause
|
||||
|
||||
In `DailyNotificationWorker.java`, the `handleDismissNotification()` method:
|
||||
1. ✅ Removes notification from storage
|
||||
2. ✅ Cancels pending alarms
|
||||
3. ❌ **MISSING**: Does not cancel the notification from NotificationManager
|
||||
|
||||
The notification is displayed with ID = `content.getId().hashCode()` (line 440), but this ID is never used to cancel the notification when dismissing.
|
||||
|
||||
## Solution
|
||||
|
||||
Add notification cancellation to `handleDismissNotification()` method in `DailyNotificationWorker.java`.
|
||||
|
||||
### IMPORTANT: Plugin Source Change
|
||||
|
||||
**This change must be applied to the plugin source repository**, not the host app. The file is located in the `@timesafari/daily-notification-plugin` package.
|
||||
|
||||
### File to Modify
|
||||
|
||||
**Plugin Source Repository:**
|
||||
`android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
|
||||
|
||||
**Note:** In the host app's `node_modules`, this file is located at:
|
||||
`node_modules/@timesafari/daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
|
||||
|
||||
However, changes to `node_modules` will be overwritten on the next `npm install`. This fix must be applied to the plugin's source repository.
|
||||
|
||||
### Change Required
|
||||
|
||||
In the `handleDismissNotification()` method (around line 177-206), add code to cancel the notification from NotificationManager:
|
||||
|
||||
```java
|
||||
private Result handleDismissNotification(String notificationId) {
|
||||
Trace.beginSection("DN:dismiss");
|
||||
try {
|
||||
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
|
||||
|
||||
// Cancel the notification from NotificationManager FIRST
|
||||
// This ensures the notification disappears immediately when dismissed
|
||||
NotificationManager notificationManager =
|
||||
(NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
if (notificationManager != null) {
|
||||
int systemNotificationId = notificationId.hashCode();
|
||||
notificationManager.cancel(systemNotificationId);
|
||||
Log.d(TAG, "DN|DISMISS_CANCEL_NOTIF systemId=" + systemNotificationId);
|
||||
}
|
||||
|
||||
// Remove from Room if present; also remove from legacy storage for compatibility
|
||||
try {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
// No direct delete DAO exposed via service; legacy removal still applied
|
||||
} catch (Exception ignored) { }
|
||||
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
|
||||
storage.removeNotification(notificationId);
|
||||
|
||||
// Cancel any pending alarms
|
||||
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
|
||||
getApplicationContext(),
|
||||
(android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
|
||||
);
|
||||
scheduler.cancelNotification(notificationId);
|
||||
|
||||
Log.i(TAG, "DN|DISMISS_OK id=" + notificationId);
|
||||
return Result.success();
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|DISMISS_ERR exception id=" + notificationId + " err=" + e.getMessage(), e);
|
||||
return Result.retry();
|
||||
} finally {
|
||||
Trace.endSection();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
1. **Notification ID**: Use `notificationId.hashCode()` to match the ID used when displaying (line 440: `int notificationId = content.getId().hashCode()`)
|
||||
2. **Order**: Cancel the notification FIRST, before removing from storage, so it disappears immediately
|
||||
3. **Null check**: Check that NotificationManager is not null before calling cancel()
|
||||
4. **Logging**: Add instrumentation log to track cancellation
|
||||
|
||||
### Expected Behavior After Fix
|
||||
|
||||
1. User clicks "Dismiss" button → Notification disappears immediately from system tray
|
||||
2. User clicks notification body → App launches (unchanged behavior)
|
||||
3. User swipes notification away → Notification dismissed (Android handles this automatically with `setAutoCancel(true)`)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Click dismiss button → Notification disappears immediately
|
||||
- [ ] Click notification body → App launches
|
||||
- [ ] Swipe notification away → Notification dismissed
|
||||
- [ ] Check logs for `DN|DISMISS_CANCEL_NOTIF` entry
|
||||
- [ ] Verify notification is removed from storage after dismiss
|
||||
- [ ] Verify alarms are cancelled after dismiss
|
||||
|
||||
## Related Code
|
||||
|
||||
- Notification display: `DailyNotificationWorker.displayNotification()` line 440
|
||||
- Notification ID generation: `content.getId().hashCode()`
|
||||
- Auto-cancel: `builder.setAutoCancel(true)` line 363 (handles swipe-to-dismiss)
|
||||
109
docs/prefetch-investigation-summary.md
Normal file
109
docs/prefetch-investigation-summary.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Prefetch Investigation Summary
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The daily notification prefetch job (T-5 min) is not calling the native fetcher, resulting in:
|
||||
- `from: null` in prefetch logs
|
||||
- Fallback/mock content being used
|
||||
- `DISPLAY_SKIP content_not_found` at notification time
|
||||
- Storage empty (`[]`) when display worker runs
|
||||
|
||||
## Root Cause Hypothesis
|
||||
|
||||
Based on the directive analysis, likely causes (ranked):
|
||||
|
||||
1. **Registration Timing**: Prefetch worker runs before `Application.onCreate()` completes
|
||||
2. **Discovery Failure**: Worker resolves fetcher to `null` (wrong scope, process mismatch)
|
||||
3. **Persistence Bug**: Content written but wiped/deduped before display
|
||||
4. **ID Mismatch**: Prefetch writes `notify_...` but display looks for `daily_...`
|
||||
|
||||
## Instrumentation Added
|
||||
|
||||
### TimeSafariApplication.java
|
||||
- `APP|ON_CREATE ts=... pid=... processName=...` - App initialization timing
|
||||
- `FETCHER|REGISTER_START instanceHash=... ts=...` - Before registration
|
||||
- `FETCHER|REGISTERED providerKey=... instanceHash=... registered=... ts=...` - After registration with verification
|
||||
|
||||
### TimeSafariNativeFetcher.java
|
||||
- `FETCHER|CONFIGURE_START instanceHash=... pid=... ts=...` - Configuration start
|
||||
- `FETCHER|CONFIGURE_COMPLETE instanceHash=... configured=... apiBaseUrl=... activeDid=... jwtLength=... ts=...` - Configuration completion
|
||||
- `PREFETCH|START id=... notifyAt=... trigger=... instanceHash=... pid=... ts=...` - Fetch start
|
||||
- `PREFETCH|SOURCE from=native/fallback reason=... ts=...` - Source resolution
|
||||
- `PREFETCH|WRITE_OK id=... items=... ts=...` - Successful fetch
|
||||
|
||||
## Diagnostic Tools
|
||||
|
||||
### Log Filtering Script
|
||||
```bash
|
||||
./scripts/diagnose-prefetch.sh app.timesafari.app
|
||||
```
|
||||
|
||||
Filters logcat for:
|
||||
- `APP|ON_CREATE`
|
||||
- `FETCHER|*`
|
||||
- `PREFETCH|*`
|
||||
- `DISPLAY|*`
|
||||
- `STORAGE|*`
|
||||
|
||||
### Manual Filtering
|
||||
```bash
|
||||
adb logcat | grep -E "APP\|ON_CREATE|FETCHER\||PREFETCH\||DISPLAY\||STORAGE\|"
|
||||
```
|
||||
|
||||
## Investigation Checklist
|
||||
|
||||
### A. App/Plugin Initialization Order
|
||||
- [ ] Confirm `APP|ON_CREATE` appears before `PREFETCH|START`
|
||||
- [ ] Verify `FETCHER|REGISTERED registered=true`
|
||||
- [ ] Check for multiple `onCreate` invocations (process restarts)
|
||||
- [ ] Confirm single process (no `android:process` on workers)
|
||||
|
||||
### B. Prefetch Worker Resolution
|
||||
- [ ] Check `PREFETCH|SOURCE from=native` (not `from=fallback`)
|
||||
- [ ] Verify `instanceHash` matches between registration and fetch
|
||||
- [ ] Compare `pid` values (should be same process)
|
||||
- [ ] Check `FETCHER|CONFIGURE_COMPLETE configured=true` before prefetch
|
||||
|
||||
### C. Storage & Persistence
|
||||
- [ ] Verify `PREFETCH|WRITE_OK items>=1`
|
||||
- [ ] Check storage logs for content persistence
|
||||
- [ ] Compare prefetch ID vs display lookup ID (must match)
|
||||
|
||||
### D. ID Schema Consistency
|
||||
- [ ] Prefetch ID format: `daily_<epoch>` or `notify_<epoch>`
|
||||
- [ ] Display lookup ID format: must match prefetch ID
|
||||
- [ ] Verify ID derivation rules are consistent
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run diagnostic script** during a notification cycle
|
||||
2. **Analyze logs** for timing issues and process mismatches
|
||||
3. **If fetcher is null**: Implement Fix #2 (Pass Fetcher Context With Work) or Fix #3 (Process-Safe DI)
|
||||
4. **If ID mismatch**: Normalize ID schema across prefetch and display
|
||||
5. **If storage issue**: Add transactional writes and read-after-write verification
|
||||
|
||||
## Expected Log Flow (Success Case)
|
||||
|
||||
```
|
||||
APP|ON_CREATE ts=... pid=... processName=app.timesafari.app
|
||||
FETCHER|REGISTER_START instanceHash=... ts=...
|
||||
FETCHER|REGISTERED providerKey=DailyNotificationPlugin instanceHash=... registered=true ts=...
|
||||
FETCHER|CONFIGURE_START instanceHash=... pid=... ts=...
|
||||
FETCHER|CONFIGURE_COMPLETE instanceHash=... configured=true ... ts=...
|
||||
PREFETCH|START id=daily_... notifyAt=... trigger=prefetch instanceHash=... pid=... ts=...
|
||||
PREFETCH|SOURCE from=native instanceHash=... apiBaseUrl=... ts=...
|
||||
PREFETCH|WRITE_OK id=daily_... items=1 ts=...
|
||||
STORAGE|POST_PREFETCH total=1 ids=[daily_...]
|
||||
DISPLAY|START id=daily_...
|
||||
STORAGE|PRE_DISPLAY total=1 ids=[daily_...]
|
||||
DISPLAY|LOOKUP result=hit id=daily_...
|
||||
```
|
||||
|
||||
## Failure Indicators
|
||||
|
||||
- `PREFETCH|SOURCE from=fallback` - Native fetcher not resolved
|
||||
- `PREFETCH|SOURCE from=null` - Fetcher registration failed
|
||||
- `FETCHER|REGISTERED registered=false` - Registration verification failed
|
||||
- `STORAGE|PRE_DISPLAY total=0` - Content not persisted
|
||||
- `DISPLAY|LOOKUP result=miss` - ID mismatch or content cleared
|
||||
|
||||
116
electron/capacitor.config.ts
Normal file
116
electron/capacitor.config.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'app.timesafari',
|
||||
appName: 'TimeSafari',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
cleartext: true
|
||||
},
|
||||
plugins: {
|
||||
App: {
|
||||
appUrlOpen: {
|
||||
handlers: [
|
||||
{
|
||||
url: 'timesafari://*',
|
||||
autoVerify: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
SplashScreen: {
|
||||
launchShowDuration: 3000,
|
||||
launchAutoHide: true,
|
||||
backgroundColor: '#ffffff',
|
||||
androidSplashResourceName: 'splash',
|
||||
androidScaleType: 'CENTER_CROP',
|
||||
showSpinner: false,
|
||||
androidSpinnerStyle: 'large',
|
||||
iosSpinnerStyle: 'small',
|
||||
spinnerColor: '#999999',
|
||||
splashFullScreen: true,
|
||||
splashImmersive: true
|
||||
},
|
||||
CapSQLite: {
|
||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
||||
iosIsEncryption: false,
|
||||
iosBiometric: {
|
||||
biometricAuth: false,
|
||||
biometricTitle: 'Biometric login for TimeSafari'
|
||||
},
|
||||
androidIsEncryption: false,
|
||||
androidBiometric: {
|
||||
biometricAuth: false,
|
||||
biometricTitle: 'Biometric login for TimeSafari'
|
||||
},
|
||||
electronIsEncryption: false
|
||||
}
|
||||
},
|
||||
ios: {
|
||||
contentInset: 'never',
|
||||
allowsLinkPreview: true,
|
||||
scrollEnabled: true,
|
||||
limitsNavigationsToAppBoundDomains: true,
|
||||
backgroundColor: '#ffffff',
|
||||
allowNavigation: [
|
||||
'*.timesafari.app',
|
||||
'*.jsdelivr.net',
|
||||
'api.endorser.ch'
|
||||
]
|
||||
},
|
||||
android: {
|
||||
allowMixedContent: true,
|
||||
captureInput: true,
|
||||
webContentsDebuggingEnabled: false,
|
||||
allowNavigation: [
|
||||
'*.timesafari.app',
|
||||
'*.jsdelivr.net',
|
||||
'api.endorser.ch',
|
||||
'10.0.2.2:3000'
|
||||
]
|
||||
},
|
||||
electron: {
|
||||
deepLinking: {
|
||||
schemes: ['timesafari']
|
||||
},
|
||||
buildOptions: {
|
||||
appId: 'app.timesafari',
|
||||
productName: 'TimeSafari',
|
||||
directories: {
|
||||
output: 'dist-electron-packages'
|
||||
},
|
||||
files: [
|
||||
'dist/**/*',
|
||||
'electron/**/*'
|
||||
],
|
||||
mac: {
|
||||
category: 'public.app-category.productivity',
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
]
|
||||
},
|
||||
win: {
|
||||
target: [
|
||||
{
|
||||
target: 'nsis',
|
||||
arch: ['x64']
|
||||
}
|
||||
]
|
||||
},
|
||||
linux: {
|
||||
target: [
|
||||
{
|
||||
target: 'AppImage',
|
||||
arch: ['x64']
|
||||
}
|
||||
],
|
||||
category: 'Utility'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
electron/package-lock.json
generated
1
electron/package-lock.json
generated
@@ -56,7 +56,6 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
|
||||
"integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jeep-sqlite": "^2.7.2"
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ process.stderr.on('error', (err) => {
|
||||
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
|
||||
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
|
||||
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
];
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export class ElectronCapacitorApp {
|
||||
];
|
||||
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
|
||||
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
];
|
||||
private mainWindowState;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compileOnSave": true,
|
||||
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
|
||||
"include": ["./src/**/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "./build",
|
||||
"importHelpers": true,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, interactive-widget=overlays-content" />
|
||||
|
||||
<!-- CORS headers removed to allow images from any domain -->
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -403,7 +403,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -413,7 +413,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MARKETING_VERSION = 1.1.2;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -430,7 +430,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 47;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -440,7 +440,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MARKETING_VERSION = 1.1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
733
package-lock.json
generated
733
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "1.1.0-beta",
|
||||
"version": "1.1.3-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "timesafari",
|
||||
"version": "1.1.0-beta",
|
||||
"version": "1.1.3-beta",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
@@ -27,6 +27,7 @@
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@jlongster/sql.js": "^1.6.7",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@simplewebauthn/server": "^10.0.0",
|
||||
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
|
||||
"@tweenjs/tween.js": "^21.1.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@veramo/core": "^5.6.0",
|
||||
@@ -90,6 +92,7 @@
|
||||
"vue": "3.5.13",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "3.0.4",
|
||||
"vue-markdown-render": "^2.2.1",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-qrcode-reader": "^5.5.3",
|
||||
"vue-router": "^4.5.0",
|
||||
@@ -106,6 +109,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/ramda": "^0.29.11",
|
||||
@@ -138,6 +142,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"serve": "^14.2.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.4.0",
|
||||
"tsx": "^4.20.4",
|
||||
@@ -145,6 +150,43 @@
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
},
|
||||
"../daily-notification-plugin": {
|
||||
"name": "@timesafari/daily-notification-plugin",
|
||||
"version": "1.0.11",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^6.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^6.2.1",
|
||||
"@capacitor/cli": "^6.2.1",
|
||||
"@capacitor/ios": "^6.2.1",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^20.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||
"@typescript-eslint/parser": "^5.57.0",
|
||||
"eslint": "^8.37.0",
|
||||
"jest": "^29.5.0",
|
||||
"jest-environment-jsdom": "^30.0.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"markdownlint-cli2": "^0.18.1",
|
||||
"prettier": "^2.8.7",
|
||||
"rimraf": "^4.4.0",
|
||||
"rollup": "^3.20.0",
|
||||
"rollup-plugin-typescript2": "^0.31.0",
|
||||
"standard-version": "^9.5.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "~5.2.0",
|
||||
"vite": "^7.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||
@@ -6785,6 +6827,17 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
|
||||
"integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "6.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
|
||||
@@ -9590,6 +9643,10 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@timesafari/daily-notification-plugin": {
|
||||
"resolved": "../daily-notification-plugin",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
@@ -10146,6 +10203,12 @@
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
|
||||
@@ -10153,6 +10216,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
|
||||
@@ -11760,6 +11839,13 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zeit/schemas": {
|
||||
"version": "2.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
|
||||
"integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@zxing/text-encoding": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||
@@ -11822,9 +11908,8 @@
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
@@ -11949,6 +12034,16 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ansi-align": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
|
||||
"integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
@@ -12153,6 +12248,27 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/arch": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
|
||||
"integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/are-we-there-yet": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
|
||||
@@ -12949,6 +13065,153 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/boxen": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz",
|
||||
"integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-align": "^3.0.1",
|
||||
"camelcase": "^7.0.0",
|
||||
"chalk": "^5.0.1",
|
||||
"cli-boxes": "^3.0.0",
|
||||
"string-width": "^5.1.2",
|
||||
"type-fest": "^2.13.0",
|
||||
"widest-line": "^4.0.1",
|
||||
"wrap-ansi": "^8.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/ansi-regex": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
||||
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/ansi-styles": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/camelcase": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
|
||||
"integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/chalk": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz",
|
||||
"integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boxen/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/strip-ansi": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/boxen/node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
"string-width": "^5.0.1",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/bplist-creator": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
|
||||
@@ -13735,6 +13998,22 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk-template": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz",
|
||||
"integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk-template?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/char-regex": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
|
||||
@@ -13959,6 +14238,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-boxes": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
||||
"integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
|
||||
@@ -14015,6 +14307,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clipboardy": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz",
|
||||
"integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"arch": "^2.2.0",
|
||||
"execa": "^5.1.1",
|
||||
"is-wsl": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@@ -14181,9 +14491,8 @@
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
@@ -14457,6 +14766,16 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
|
||||
"integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog": {
|
||||
"version": "3.1.25",
|
||||
"resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-3.1.25.tgz",
|
||||
@@ -19249,6 +19568,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-port-reachable": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz",
|
||||
"integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -24440,9 +24772,8 @@
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -25906,6 +26237,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-inside": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
|
||||
"integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==",
|
||||
"dev": true,
|
||||
"license": "(WTFPL OR MIT)"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -25947,6 +26285,13 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
|
||||
"integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
@@ -28112,6 +28457,30 @@
|
||||
"integrity": "sha512-CiD3ZSanZqcMPRhtfct5K9f7i3OLCcBBWsJjLh1gW9RO/nS94sVzY59iS+fgYBOBqaBpf4EzfqUF3j9IG+xo8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/registry-auth-token": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",
|
||||
"integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rc": "^1.1.6",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/registry-url": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz",
|
||||
"integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rc": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regjsgen": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
|
||||
@@ -29220,6 +29589,115 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/serve": {
|
||||
"version": "14.2.4",
|
||||
"resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz",
|
||||
"integrity": "sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zeit/schemas": "2.36.0",
|
||||
"ajv": "8.12.0",
|
||||
"arg": "5.0.2",
|
||||
"boxen": "7.0.0",
|
||||
"chalk": "5.0.1",
|
||||
"chalk-template": "0.4.0",
|
||||
"clipboardy": "3.0.0",
|
||||
"compression": "1.7.4",
|
||||
"is-port-reachable": "4.0.0",
|
||||
"serve-handler": "6.1.6",
|
||||
"update-check": "1.5.4"
|
||||
},
|
||||
"bin": {
|
||||
"serve": "build/main.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz",
|
||||
"integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.0.0",
|
||||
"content-disposition": "0.5.2",
|
||||
"mime-types": "2.1.18",
|
||||
"minimatch": "3.1.2",
|
||||
"path-is-inside": "1.0.2",
|
||||
"path-to-regexp": "3.3.0",
|
||||
"range-parser": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/bytes": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
|
||||
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/mime-db": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
|
||||
"integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/mime-types": {
|
||||
"version": "2.1.18",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
|
||||
"integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "~1.33.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-handler/node_modules/range-parser": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
|
||||
"integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
@@ -29351,6 +29829,106 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve/node_modules/ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/serve/node_modules/bytes": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
|
||||
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve/node_modules/chalk": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz",
|
||||
"integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/serve/node_modules/compression": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz",
|
||||
"integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.5",
|
||||
"bytes": "3.0.0",
|
||||
"compressible": "~2.0.16",
|
||||
"debug": "2.6.9",
|
||||
"on-headers": "~1.0.2",
|
||||
"safe-buffer": "5.1.2",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve/node_modules/on-headers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz",
|
||||
"integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
@@ -31680,6 +32258,17 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-check": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz",
|
||||
"integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"registry-auth-token": "3.3.2",
|
||||
"registry-url": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@@ -31788,9 +32377,8 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -32373,6 +32961,61 @@
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-markdown-render": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-markdown-render/-/vue-markdown-render-2.2.1.tgz",
|
||||
"integrity": "sha512-XkYnC0PMdbs6Vy6j/gZXSvCuOS0787Se5COwXlepRqiqPiunyCIeTPQAO2XnB4Yl04EOHXwLx5y6IuszMWSgyQ==",
|
||||
"dependencies": {
|
||||
"markdown-it": "^13.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-markdown-render/node_modules/entities": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
|
||||
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-markdown-render/node_modules/linkify-it": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
|
||||
"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
|
||||
"dependencies": {
|
||||
"uc.micro": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-markdown-render/node_modules/markdown-it": {
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz",
|
||||
"integrity": "sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "~3.0.1",
|
||||
"linkify-it": "^4.0.1",
|
||||
"mdurl": "^1.0.1",
|
||||
"uc.micro": "^1.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.js"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-markdown-render/node_modules/mdurl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
|
||||
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
|
||||
},
|
||||
"node_modules/vue-markdown-render/node_modules/uc.micro": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
|
||||
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
|
||||
},
|
||||
"node_modules/vue-picture-cropper": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz",
|
||||
@@ -32642,6 +33285,76 @@
|
||||
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||
}
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
|
||||
"integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"string-width": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/widest-line/node_modules/ansi-regex": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
||||
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/widest-line/node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/widest-line/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"strip-ansi": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/widest-line/node_modules/strip-ansi": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wonka": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz",
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "1.1.0-beta",
|
||||
"version": "1.1.3-beta",
|
||||
"description": "Time Safari Application",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
@@ -106,7 +106,7 @@
|
||||
"guard": "bash ./scripts/build-arch-guard.sh",
|
||||
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
|
||||
"guard:setup": "npm run prepare && echo '✅ Build Architecture Guard is now active!'",
|
||||
"clean:android": "./scripts/clean-android.sh",
|
||||
"clean:android": "./scripts/uninstall-android.sh",
|
||||
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
||||
"clean:electron": "./scripts/build-electron.sh --clean",
|
||||
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
|
||||
@@ -136,7 +136,6 @@
|
||||
"*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true",
|
||||
"*.{md,markdown,mdc}": "markdownlint-cli2 --fix"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
@@ -157,6 +156,7 @@
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||
"@jlongster/sql.js": "^1.6.7",
|
||||
@@ -166,6 +166,7 @@
|
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@simplewebauthn/server": "^10.0.0",
|
||||
"@timesafari/daily-notification-plugin": "file:../daily-notification-plugin",
|
||||
"@tweenjs/tween.js": "^21.1.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@veramo/core": "^5.6.0",
|
||||
@@ -220,6 +221,7 @@
|
||||
"vue": "3.5.13",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "3.0.4",
|
||||
"vue-markdown-render": "^2.2.1",
|
||||
"vue-picture-cropper": "^0.7.0",
|
||||
"vue-qrcode-reader": "^5.5.3",
|
||||
"vue-router": "^4.5.0",
|
||||
@@ -236,6 +238,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/ramda": "^0.29.11",
|
||||
@@ -268,6 +271,7 @@
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"serve": "^14.2.4",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.4.0",
|
||||
"tsx": "^4.20.4",
|
||||
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
workers: 4,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [
|
||||
['list'],
|
||||
|
||||
46
public/manifest.webmanifest
Normal file
46
public/manifest.webmanifest
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "../icons/icon-48.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-72.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-96.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-128.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "128x128",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-192.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-256.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "../icons/icon-512.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
389
scripts/avd-resource-checker.sh
Executable file
389
scripts/avd-resource-checker.sh
Executable file
@@ -0,0 +1,389 @@
|
||||
#!/bin/bash
|
||||
# avd-resource-checker.sh
|
||||
# Author: Matthew Raymer
|
||||
# Date: 2025-01-27
|
||||
# Description: Check system resources and recommend optimal AVD configuration
|
||||
|
||||
set -e
|
||||
|
||||
# Source common utilities
|
||||
source "$(dirname "$0")/common.sh"
|
||||
|
||||
# Colors for output
|
||||
RED_COLOR='\033[0;31m'
|
||||
GREEN_COLOR='\033[0;32m'
|
||||
YELLOW_COLOR='\033[1;33m'
|
||||
BLUE_COLOR='\033[0;34m'
|
||||
NC_COLOR='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
local color=$1
|
||||
local message=$2
|
||||
echo -e "${color}${message}${NC_COLOR}"
|
||||
}
|
||||
|
||||
# Function to get system memory in MB
|
||||
get_system_memory() {
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
free -m | awk 'NR==2{print $2}'
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get available memory in MB
|
||||
get_available_memory() {
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
free -m | awk 'NR==2{print $7}'
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get CPU core count
|
||||
get_cpu_cores() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif [ -f /proc/cpuinfo ]; then
|
||||
grep -c ^processor /proc/cpuinfo
|
||||
else
|
||||
echo "1"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check GPU capabilities
|
||||
check_gpu_capabilities() {
|
||||
local gpu_type="unknown"
|
||||
local gpu_memory="0"
|
||||
|
||||
# Check for NVIDIA GPU
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
gpu_type="nvidia"
|
||||
gpu_memory=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 || echo "0")
|
||||
print_status $GREEN_COLOR "✓ NVIDIA GPU detected (${gpu_memory}MB VRAM)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for AMD GPU
|
||||
if command -v rocm-smi >/dev/null 2>&1; then
|
||||
gpu_type="amd"
|
||||
print_status $GREEN_COLOR "✓ AMD GPU detected"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Check for Intel GPU
|
||||
if lspci 2>/dev/null | grep -i "vga.*intel" >/dev/null; then
|
||||
gpu_type="intel"
|
||||
print_status $YELLOW_COLOR "✓ Intel integrated GPU detected"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for generic GPU
|
||||
if lspci 2>/dev/null | grep -i "vga" >/dev/null; then
|
||||
gpu_type="generic"
|
||||
print_status $YELLOW_COLOR "✓ Generic GPU detected"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_status $RED_COLOR "✗ No GPU detected"
|
||||
return 2
|
||||
}
|
||||
|
||||
# Function to check if hardware acceleration is available
|
||||
check_hardware_acceleration() {
|
||||
local gpu_capable=$1
|
||||
|
||||
if [ $gpu_capable -eq 0 ]; then
|
||||
print_status $GREEN_COLOR "✓ Hardware acceleration recommended"
|
||||
return 0
|
||||
elif [ $gpu_capable -eq 1 ]; then
|
||||
print_status $YELLOW_COLOR "⚠ Limited hardware acceleration"
|
||||
return 1
|
||||
else
|
||||
print_status $RED_COLOR "✗ No hardware acceleration available"
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to recommend AVD configuration
|
||||
recommend_avd_config() {
|
||||
local total_memory=$1
|
||||
local available_memory=$2
|
||||
local cpu_cores=$3
|
||||
local gpu_capable=$4
|
||||
|
||||
print_status $BLUE_COLOR "\n=== AVD Configuration Recommendation ==="
|
||||
|
||||
# Calculate recommended memory (leave 2GB for system)
|
||||
local system_reserve=2048
|
||||
local recommended_memory=$((available_memory - system_reserve))
|
||||
|
||||
# Cap memory at reasonable limits
|
||||
if [ $recommended_memory -gt 4096 ]; then
|
||||
recommended_memory=4096
|
||||
elif [ $recommended_memory -lt 1024 ]; then
|
||||
recommended_memory=1024
|
||||
fi
|
||||
|
||||
# Calculate recommended cores (leave 2 cores for system)
|
||||
local recommended_cores=$((cpu_cores - 2))
|
||||
if [ $recommended_cores -lt 1 ]; then
|
||||
recommended_cores=1
|
||||
elif [ $recommended_cores -gt 4 ]; then
|
||||
recommended_cores=4
|
||||
fi
|
||||
|
||||
# Determine GPU setting
|
||||
local gpu_setting=""
|
||||
case $gpu_capable in
|
||||
0) gpu_setting="-gpu host" ;;
|
||||
1) gpu_setting="-gpu swiftshader_indirect" ;;
|
||||
2) gpu_setting="-gpu swiftshader_indirect" ;;
|
||||
esac
|
||||
|
||||
# Generate recommendation
|
||||
print_status $GREEN_COLOR "Recommended AVD Configuration:"
|
||||
echo " Memory: ${recommended_memory}MB"
|
||||
echo " Cores: ${recommended_cores}"
|
||||
echo " GPU: ${gpu_setting}"
|
||||
|
||||
# Get AVD name from function parameter (passed from main)
|
||||
local avd_name=$5
|
||||
local command="emulator -avd ${avd_name} -no-audio -memory ${recommended_memory} -cores ${recommended_cores} ${gpu_setting} &"
|
||||
|
||||
print_status $BLUE_COLOR "\nGenerated Command:"
|
||||
echo " ${command}"
|
||||
|
||||
# Save to file for easy execution
|
||||
local script_file="/tmp/start-avd-${avd_name}.sh"
|
||||
cat > "$script_file" << EOF
|
||||
#!/bin/bash
|
||||
# Auto-generated AVD startup script
|
||||
# Generated by avd-resource-checker.sh on $(date)
|
||||
|
||||
echo "Starting AVD: ${avd_name}"
|
||||
echo "Memory: ${recommended_memory}MB"
|
||||
echo "Cores: ${recommended_cores}"
|
||||
echo "GPU: ${gpu_setting}"
|
||||
|
||||
${command}
|
||||
|
||||
echo "AVD started in background"
|
||||
echo "Check status with: adb devices"
|
||||
echo "View logs with: adb logcat"
|
||||
EOF
|
||||
|
||||
chmod +x "$script_file"
|
||||
print_status $GREEN_COLOR "\n✓ Startup script saved to: ${script_file}"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to test AVD startup
|
||||
test_avd_startup() {
|
||||
local avd_name=$1
|
||||
local test_duration=${2:-30}
|
||||
|
||||
print_status $BLUE_COLOR "\n=== Testing AVD Startup ==="
|
||||
|
||||
# Check if AVD exists
|
||||
if ! avdmanager list avd | grep -q "$avd_name"; then
|
||||
print_status $RED_COLOR "✗ AVD '$avd_name' not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_status $YELLOW_COLOR "Testing AVD startup for ${test_duration} seconds..."
|
||||
|
||||
# Start emulator in test mode
|
||||
emulator -avd "$avd_name" -no-audio -no-window -no-snapshot -memory 1024 -cores 1 -gpu swiftshader_indirect &
|
||||
local emulator_pid=$!
|
||||
|
||||
# Wait for boot
|
||||
local boot_time=0
|
||||
local max_wait=$test_duration
|
||||
|
||||
while [ $boot_time -lt $max_wait ]; do
|
||||
if adb devices | grep -q "emulator.*device"; then
|
||||
print_status $GREEN_COLOR "✓ AVD booted successfully in ${boot_time} seconds"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
boot_time=$((boot_time + 2))
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
kill $emulator_pid 2>/dev/null || true
|
||||
adb emu kill 2>/dev/null || true
|
||||
|
||||
if [ $boot_time -ge $max_wait ]; then
|
||||
print_status $RED_COLOR "✗ AVD failed to boot within ${test_duration} seconds"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to list available AVDs
|
||||
list_available_avds() {
|
||||
print_status $BLUE_COLOR "\n=== Available AVDs ==="
|
||||
|
||||
if ! command -v avdmanager >/dev/null 2>&1; then
|
||||
print_status $RED_COLOR "✗ avdmanager not found. Please install Android SDK command line tools."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local avd_list=$(avdmanager list avd 2>/dev/null)
|
||||
if [ -z "$avd_list" ]; then
|
||||
print_status $YELLOW_COLOR "⚠ No AVDs found. Create one with:"
|
||||
echo " avdmanager create avd --name TimeSafari_Emulator --package system-images;android-34;google_apis;x86_64"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$avd_list"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to create optimized AVD
|
||||
create_optimized_avd() {
|
||||
local avd_name=$1
|
||||
local memory=$2
|
||||
local cores=$3
|
||||
|
||||
print_status $BLUE_COLOR "\n=== Creating Optimized AVD ==="
|
||||
|
||||
# Check if system image is available
|
||||
local system_image="system-images;android-34;google_apis;x86_64"
|
||||
if ! sdkmanager --list | grep -q "$system_image"; then
|
||||
print_status $YELLOW_COLOR "Installing system image: $system_image"
|
||||
sdkmanager "$system_image"
|
||||
fi
|
||||
|
||||
# Create AVD
|
||||
print_status $YELLOW_COLOR "Creating AVD: $avd_name"
|
||||
avdmanager create avd \
|
||||
--name "$avd_name" \
|
||||
--package "$system_image" \
|
||||
--device "pixel_7" \
|
||||
--force
|
||||
|
||||
# Configure AVD
|
||||
local avd_config_file="$HOME/.android/avd/${avd_name}.avd/config.ini"
|
||||
if [ -f "$avd_config_file" ]; then
|
||||
print_status $YELLOW_COLOR "Configuring AVD settings..."
|
||||
|
||||
# Set memory
|
||||
sed -i "s/vm.heapSize=.*/vm.heapSize=${memory}/" "$avd_config_file"
|
||||
|
||||
# Set cores
|
||||
sed -i "s/hw.cpu.ncore=.*/hw.cpu.ncore=${cores}/" "$avd_config_file"
|
||||
|
||||
# Disable unnecessary features
|
||||
echo "hw.audioInput=no" >> "$avd_config_file"
|
||||
echo "hw.audioOutput=no" >> "$avd_config_file"
|
||||
echo "hw.camera.back=none" >> "$avd_config_file"
|
||||
echo "hw.camera.front=none" >> "$avd_config_file"
|
||||
echo "hw.gps=no" >> "$avd_config_file"
|
||||
echo "hw.sensors.orientation=no" >> "$avd_config_file"
|
||||
echo "hw.sensors.proximity=no" >> "$avd_config_file"
|
||||
|
||||
print_status $GREEN_COLOR "✓ AVD configured successfully"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
print_status $BLUE_COLOR "=== TimeSafari AVD Resource Checker ==="
|
||||
print_status $BLUE_COLOR "Checking system resources and recommending optimal AVD configuration\n"
|
||||
|
||||
# Get system information
|
||||
local total_memory=$(get_system_memory)
|
||||
local available_memory=$(get_available_memory)
|
||||
local cpu_cores=$(get_cpu_cores)
|
||||
|
||||
print_status $BLUE_COLOR "=== System Information ==="
|
||||
echo "Total Memory: ${total_memory}MB"
|
||||
echo "Available Memory: ${available_memory}MB"
|
||||
echo "CPU Cores: ${cpu_cores}"
|
||||
|
||||
# Check GPU capabilities
|
||||
print_status $BLUE_COLOR "\n=== GPU Analysis ==="
|
||||
check_gpu_capabilities
|
||||
local gpu_capable=$?
|
||||
|
||||
# Check hardware acceleration
|
||||
check_hardware_acceleration $gpu_capable
|
||||
local hw_accel=$?
|
||||
|
||||
# List available AVDs
|
||||
list_available_avds
|
||||
|
||||
# Get AVD name from user or use default
|
||||
local avd_name="TimeSafari_Emulator"
|
||||
if [ $# -gt 0 ]; then
|
||||
avd_name="$1"
|
||||
fi
|
||||
|
||||
# Recommend configuration
|
||||
recommend_avd_config $total_memory $available_memory $cpu_cores $gpu_capable "$avd_name"
|
||||
|
||||
# Test AVD if requested
|
||||
if [ "$2" = "--test" ]; then
|
||||
test_avd_startup "$avd_name"
|
||||
fi
|
||||
|
||||
# Create optimized AVD if requested
|
||||
if [ "$2" = "--create" ]; then
|
||||
local recommended_memory=$((available_memory - 2048))
|
||||
if [ $recommended_memory -gt 4096 ]; then
|
||||
recommended_memory=4096
|
||||
elif [ $recommended_memory -lt 1024 ]; then
|
||||
recommended_memory=1024
|
||||
fi
|
||||
|
||||
local recommended_cores=$((cpu_cores - 2))
|
||||
if [ $recommended_cores -lt 1 ]; then
|
||||
recommended_cores=1
|
||||
elif [ $recommended_cores -gt 4 ]; then
|
||||
recommended_cores=4
|
||||
fi
|
||||
|
||||
create_optimized_avd "$avd_name" $recommended_memory $recommended_cores
|
||||
fi
|
||||
|
||||
print_status $GREEN_COLOR "\n=== Resource Check Complete ==="
|
||||
print_status $YELLOW_COLOR "Tip: Use the generated startup script for consistent AVD launches"
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Usage: $0 [AVD_NAME] [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --test Test AVD startup (30 second test)"
|
||||
echo " --create Create optimized AVD with recommended settings"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Check resources and recommend config"
|
||||
echo " $0 TimeSafari_Emulator # Check resources for specific AVD"
|
||||
echo " $0 TimeSafari_Emulator --test # Test AVD startup"
|
||||
echo " $0 TimeSafari_Emulator --create # Create optimized AVD"
|
||||
echo ""
|
||||
echo "The script will:"
|
||||
echo " - Analyze system resources (RAM, CPU, GPU)"
|
||||
echo " - Recommend optimal AVD configuration"
|
||||
echo " - Generate startup command and script"
|
||||
echo " - Optionally test or create AVD"
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -22,6 +22,7 @@
|
||||
# --sync Sync Capacitor only
|
||||
# --assets Generate assets only
|
||||
# --deploy Deploy APK to connected device
|
||||
# --uninstall Uninstall app from connected device
|
||||
# -h, --help Show this help message
|
||||
# -v, --verbose Enable verbose logging
|
||||
#
|
||||
@@ -196,6 +197,7 @@ SYNC_ONLY=false
|
||||
ASSETS_ONLY=false
|
||||
DEPLOY_APP=false
|
||||
AUTO_RUN=false
|
||||
UNINSTALL=false
|
||||
CUSTOM_API_IP=""
|
||||
|
||||
# Function to parse Android-specific arguments
|
||||
@@ -246,6 +248,9 @@ parse_android_args() {
|
||||
--auto-run)
|
||||
AUTO_RUN=true
|
||||
;;
|
||||
--uninstall)
|
||||
UNINSTALL=true
|
||||
;;
|
||||
--api-ip)
|
||||
if [ $((i + 1)) -lt ${#args[@]} ]; then
|
||||
CUSTOM_API_IP="${args[$((i + 1))]}"
|
||||
@@ -291,6 +296,7 @@ print_android_usage() {
|
||||
echo " --assets Generate assets only"
|
||||
echo " --deploy Deploy APK to connected device"
|
||||
echo " --auto-run Auto-run app after build"
|
||||
echo " --uninstall Uninstall app from connected device"
|
||||
echo " --api-ip <ip> Custom IP address for claim API (defaults to 10.0.2.2)"
|
||||
echo ""
|
||||
echo "Common Options:"
|
||||
@@ -305,6 +311,7 @@ print_android_usage() {
|
||||
echo " $0 --clean # Clean only"
|
||||
echo " $0 --sync # Sync only"
|
||||
echo " $0 --deploy # Build and deploy to device"
|
||||
echo " $0 --uninstall # Uninstall app from device"
|
||||
echo " $0 --dev # Dev build with default 10.0.2.2"
|
||||
echo " $0 --dev --api-ip 192.168.1.100 # Dev build with custom API IP"
|
||||
echo ""
|
||||
@@ -351,8 +358,18 @@ fi
|
||||
# Setup application directories
|
||||
setup_app_directories
|
||||
|
||||
# Load environment from .env file if it exists
|
||||
load_env_file ".env"
|
||||
# Load environment-specific .env file if it exists
|
||||
env_file=".env.$BUILD_MODE"
|
||||
if [ -f "$env_file" ]; then
|
||||
load_env_file "$env_file"
|
||||
else
|
||||
log_debug "No $env_file file found, using default environment"
|
||||
fi
|
||||
|
||||
# Load .env file if it exists (fallback)
|
||||
if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Handle clean-only mode
|
||||
if [ "$CLEAN_ONLY" = true ]; then
|
||||
@@ -407,14 +424,33 @@ safe_execute "Validating asset configuration" "npm run assets:validate" || {
|
||||
log_info "If you encounter build failures, please run 'npm install' first to ensure all dependencies are available."
|
||||
}
|
||||
|
||||
# Step 2: Clean Android app
|
||||
safe_execute "Cleaning Android app" "npm run clean:android" || exit 1
|
||||
# Step 2: Uninstall Android app
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
log_info "Uninstall: uninstalling app from device"
|
||||
safe_execute "Uninstalling Android app" "./scripts/uninstall-android.sh" || exit 1
|
||||
log_success "Uninstall completed successfully!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Step 3: Clean dist directory
|
||||
log_info "Cleaning dist directory..."
|
||||
clean_build_artifacts "dist"
|
||||
|
||||
# Step 4: Build Capacitor version with mode
|
||||
# Step 4: Run TypeScript type checking for test and production builds
|
||||
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
|
||||
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
|
||||
|
||||
if ! measure_time npm run type-check; then
|
||||
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log_success "TypeScript type checking completed for $BUILD_MODE mode"
|
||||
else
|
||||
log_debug "Skipping TypeScript type checking for development mode"
|
||||
fi
|
||||
|
||||
# Step 5: Build Capacitor version with mode
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||
elif [ "$BUILD_MODE" = "test" ]; then
|
||||
@@ -423,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||
fi
|
||||
|
||||
# Step 5: Clean Gradle build
|
||||
# Step 6: Clean Gradle build
|
||||
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
|
||||
|
||||
# Step 6: Build based on type
|
||||
# Step 7: Build based on type
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||
elif [ "$BUILD_TYPE" = "release" ]; then
|
||||
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
|
||||
fi
|
||||
|
||||
# Step 7: Sync with Capacitor
|
||||
# Step 8: Sync with Capacitor
|
||||
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
|
||||
|
||||
# Step 8: Generate assets
|
||||
# Step 9: Generate assets
|
||||
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
|
||||
|
||||
# Step 9: Build APK/AAB if requested
|
||||
# Step 10: Build APK/AAB if requested
|
||||
if [ "$BUILD_APK" = true ]; then
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
|
||||
@@ -452,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
|
||||
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
|
||||
fi
|
||||
|
||||
# Step 10: Auto-run app if requested
|
||||
# Step 11: Auto-run app if requested
|
||||
if [ "$AUTO_RUN" = true ]; then
|
||||
log_step "Auto-running Android app..."
|
||||
safe_execute "Launching app" "npx cap run android" || {
|
||||
@@ -463,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
|
||||
log_success "Android app launched successfully!"
|
||||
fi
|
||||
|
||||
# Step 11: Open Android Studio if requested
|
||||
# Step 12: Open Android Studio if requested
|
||||
if [ "$OPEN_STUDIO" = true ]; then
|
||||
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
|
||||
fi
|
||||
|
||||
@@ -181,7 +181,7 @@ sync_capacitor() {
|
||||
copy_web_assets() {
|
||||
log_info "Copying web assets to Electron"
|
||||
safe_execute "Copying assets" "cp -r dist/* electron/app/"
|
||||
safe_execute "Copying config" "cp capacitor.config.json electron/capacitor.config.json"
|
||||
# Note: Electron has its own capacitor.config.ts file, so we don't copy the main config
|
||||
}
|
||||
|
||||
# Compile TypeScript
|
||||
@@ -341,7 +341,19 @@ main_electron_build() {
|
||||
# Setup environment
|
||||
setup_build_env "electron" "$BUILD_MODE"
|
||||
setup_app_directories
|
||||
load_env_file ".env"
|
||||
|
||||
# Load environment-specific .env file if it exists
|
||||
env_file=".env.$BUILD_MODE"
|
||||
if [ -f "$env_file" ]; then
|
||||
load_env_file "$env_file"
|
||||
else
|
||||
log_debug "No $env_file file found, using default environment"
|
||||
fi
|
||||
|
||||
# Load .env file if it exists (fallback)
|
||||
if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Step 1: Clean Electron build artifacts
|
||||
clean_electron_artifacts
|
||||
|
||||
@@ -324,8 +324,18 @@ fi
|
||||
# Setup application directories
|
||||
setup_app_directories
|
||||
|
||||
# Load environment from .env file if it exists
|
||||
load_env_file ".env"
|
||||
# Load environment-specific .env file if it exists
|
||||
env_file=".env.$BUILD_MODE"
|
||||
if [ -f "$env_file" ]; then
|
||||
load_env_file "$env_file"
|
||||
else
|
||||
log_debug "No $env_file file found, using default environment"
|
||||
fi
|
||||
|
||||
# Load .env file if it exists (fallback)
|
||||
if [ -f ".env" ]; then
|
||||
load_env_file ".env"
|
||||
fi
|
||||
|
||||
# Validate iOS environment
|
||||
validate_ios_environment
|
||||
@@ -371,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
|
||||
log_info "Cleaning dist directory..."
|
||||
clean_build_artifacts "dist"
|
||||
|
||||
# Step 4: Build Capacitor version with mode
|
||||
# Step 4: Run TypeScript type checking for test and production builds
|
||||
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
|
||||
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
|
||||
|
||||
if ! measure_time npm run type-check; then
|
||||
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log_success "TypeScript type checking completed for $BUILD_MODE mode"
|
||||
else
|
||||
log_debug "Skipping TypeScript type checking for development mode"
|
||||
fi
|
||||
|
||||
# Step 5: Build Capacitor version with mode
|
||||
if [ "$BUILD_MODE" = "development" ]; then
|
||||
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
|
||||
elif [ "$BUILD_MODE" = "test" ]; then
|
||||
@@ -380,16 +404,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
|
||||
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
|
||||
fi
|
||||
|
||||
# Step 5: Sync with Capacitor
|
||||
# Step 6: Sync with Capacitor
|
||||
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
|
||||
|
||||
# Step 6: Generate assets
|
||||
# Step 7: Generate assets
|
||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||
|
||||
# Step 7: Build iOS app
|
||||
# Step 8: Build iOS app
|
||||
safe_execute "Building iOS app" "build_ios_app" || exit 5
|
||||
|
||||
# Step 8: Build IPA/App if requested
|
||||
# Step 9: Build IPA/App if requested
|
||||
if [ "$BUILD_IPA" = true ]; then
|
||||
log_info "Building IPA package..."
|
||||
cd ios/App
|
||||
@@ -416,12 +440,12 @@ if [ "$BUILD_APP" = true ]; then
|
||||
log_success "App bundle built successfully"
|
||||
fi
|
||||
|
||||
# Step 9: Auto-run app if requested
|
||||
# Step 10: Auto-run app if requested
|
||||
if [ "$AUTO_RUN" = true ]; then
|
||||
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
|
||||
fi
|
||||
|
||||
# Step 10: Open Xcode if requested
|
||||
# Step 11: Open Xcode if requested
|
||||
if [ "$OPEN_STUDIO" = true ]; then
|
||||
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
|
||||
fi
|
||||
|
||||
36
scripts/diagnose-prefetch.sh
Executable file
36
scripts/diagnose-prefetch.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Diagnostic script for daily notification prefetch issues
|
||||
# Filters logcat output for prefetch-related instrumentation logs
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/diagnose-prefetch.sh [package_name]
|
||||
#
|
||||
# Example:
|
||||
# ./scripts/diagnose-prefetch.sh app.timesafari.app
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
PACKAGE_NAME="${1:-app.timesafari.app}"
|
||||
|
||||
echo "🔍 Daily Notification Prefetch Diagnostic Tool"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
echo "Package: $PACKAGE_NAME"
|
||||
echo "Filtering for instrumentation tags:"
|
||||
echo " - APP|ON_CREATE"
|
||||
echo " - FETCHER|*"
|
||||
echo " - PREFETCH|*"
|
||||
echo " - DISPLAY|*"
|
||||
echo " - STORAGE|*"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Filter logcat for instrumentation tags
|
||||
adb logcat -c # Clear logcat buffer first
|
||||
|
||||
adb logcat | grep -E "APP\|ON_CREATE|FETCHER\||PREFETCH\||DISPLAY\||STORAGE\||DailyNotification|TimeSafariApplication|TimeSafariNativeFetcher" | \
|
||||
grep -i "$PACKAGE_NAME\|TimeSafari\|DailyNotification"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
# clean-android.sh
|
||||
# uninstall-android.sh
|
||||
# Author: Matthew Raymer
|
||||
# Date: 2025-08-19
|
||||
# Description: Clean Android app with timeout protection to prevent hanging
|
||||
# Description: Uninstall Android app with timeout protection to prevent hanging
|
||||
# This script safely uninstalls the TimeSafari app from connected Android devices
|
||||
# with a 30-second timeout to prevent indefinite hanging.
|
||||
|
||||
@@ -386,7 +386,7 @@ export default class App extends Vue {
|
||||
let allGoingOff = false;
|
||||
|
||||
try {
|
||||
const settings: Settings = await this.$settings();
|
||||
const settings: Settings = await this.$accountSettings();
|
||||
|
||||
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
|
||||
const notifyingReminder = !!settings?.notifyingReminderTime;
|
||||
|
||||
@@ -7,6 +7,24 @@
|
||||
html {
|
||||
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
/* Fix iOS viewport height changes when keyboard appears/disappears */
|
||||
html, body {
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh; /* Dynamic viewport height for better mobile support */
|
||||
overflow: hidden; /* Disable all scrolling on html and body */
|
||||
position: fixed; /* Force fixed positioning to prevent viewport changes */
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -20,6 +38,26 @@
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@apply bg-white p-4 rounded-lg w-full max-w-lg;
|
||||
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
|
||||
}
|
||||
|
||||
/* Markdown content styling to restore list elements */
|
||||
.markdown-content ul {
|
||||
@apply list-disc list-inside ml-4;
|
||||
}
|
||||
|
||||
.markdown-content ol {
|
||||
@apply list-decimal list-inside ml-4;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
@apply mb-1;
|
||||
}
|
||||
|
||||
.markdown-content ul ul,
|
||||
.markdown-content ol ol,
|
||||
.markdown-content ul ol,
|
||||
.markdown-content ol ul {
|
||||
@apply ml-4 mt-1;
|
||||
}
|
||||
}
|
||||
@@ -77,15 +77,95 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Emoji Section -->
|
||||
<div
|
||||
v-if="hasEmojis || isRegistered"
|
||||
class="float-right ml-3 mb-1 bg-white rounded border border-slate-300 px-1.5 py-0.5 max-w-[240px]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<!-- Existing Emojis Display -->
|
||||
<div v-if="hasEmojis" class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="(count, emoji) in record.emojiCount"
|
||||
:key="emoji"
|
||||
class="inline-flex items-center gap-0.5 px-1 py-0.5 text-xs bg-slate-50 hover:bg-slate-100 rounded border border-slate-200 transition-colors cursor-pointer"
|
||||
:class="{
|
||||
'bg-blue-50 border-blue-200': isUserEmojiWithoutLoading(emoji),
|
||||
'opacity-75 cursor-wait': loadingEmojis,
|
||||
}"
|
||||
:title="
|
||||
loadingEmojis
|
||||
? 'Loading...'
|
||||
: !emojisOnActivity?.isResolved
|
||||
? 'Click to load your emojis'
|
||||
: isUserEmojiWithoutLoading(emoji)
|
||||
? 'Click to remove your emoji'
|
||||
: 'Click to add this emoji'
|
||||
"
|
||||
:disabled="!isRegistered"
|
||||
@click="toggleThisEmoji(emoji)"
|
||||
>
|
||||
<!-- Show spinner when loading -->
|
||||
<div v-if="loadingEmojis" class="animate-spin text-xs">
|
||||
<font-awesome icon="spinner" class="fa-spin" />
|
||||
</div>
|
||||
<span v-else class="text-sm leading-none">{{ emoji }}</span>
|
||||
<span class="text-xs text-slate-600 font-medium leading-none">{{
|
||||
count
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Emoji Button -->
|
||||
<button
|
||||
v-if="isRegistered"
|
||||
class="inline-flex px-1 py-0.5 text-xs bg-slate-100 hover:bg-slate-200 rounded border border-slate-300 transition-colors items-center justify-center ml-2 ml-auto"
|
||||
:title="showEmojiPicker ? 'Close emoji picker' : 'Add emoji'"
|
||||
@click="toggleEmojiPicker"
|
||||
>
|
||||
<span class="px-2 text-sm leading-none">{{
|
||||
showEmojiPicker ? "x" : "😊"
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emoji Picker (placeholder for now) -->
|
||||
<div
|
||||
v-if="showEmojiPicker"
|
||||
class="mt-1 p-1.5 bg-slate-50 rounded border border-slate-300"
|
||||
>
|
||||
<!-- Temporary emoji buttons for testing -->
|
||||
<div class="flex flex-wrap gap-3 mt-1">
|
||||
<button
|
||||
v-for="emoji in QUICK_EMOJIS"
|
||||
:key="emoji"
|
||||
class="p-0.5 hover:bg-slate-200 rounded text-base transition-opacity"
|
||||
:class="{
|
||||
'opacity-75 cursor-wait': loadingEmojis,
|
||||
}"
|
||||
:disabled="loadingEmojis"
|
||||
@click="toggleThisEmoji(emoji)"
|
||||
>
|
||||
<!-- Show spinner when loading -->
|
||||
<div v-if="loadingEmojis" class="animate-spin text-sm">⟳</div>
|
||||
<span v-else>{{ emoji }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="font-medium">
|
||||
<a class="cursor-pointer" @click="emitLoadClaim(record.jwtId)">
|
||||
{{ description }}
|
||||
<a class="block cursor-pointer" @click="emitLoadClaim(record.jwtId)">
|
||||
<vue-markdown
|
||||
:source="truncatedDescription"
|
||||
class="markdown-content"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
|
||||
class="clear-right relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
|
||||
>
|
||||
<!-- Source -->
|
||||
<div
|
||||
@@ -248,33 +328,51 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import { GiveRecordWithContactInfo } from "@/interfaces/give";
|
||||
import VueMarkdown from "vue-markdown-render";
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
createAndSubmitClaim,
|
||||
getHeaders,
|
||||
isHiddenDid,
|
||||
} from "../libs/endorserServer";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { isHiddenDid } from "../libs/endorserServer";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { createNotifyHelpers, NotifyFunction } from "@/utils/notify";
|
||||
import { createNotifyHelpers, NotifyFunction, TIMEOUTS } from "@/utils/notify";
|
||||
import {
|
||||
NOTIFY_PERSON_HIDDEN,
|
||||
NOTIFY_UNKNOWN_PERSON,
|
||||
} from "@/constants/notifications";
|
||||
import { TIMEOUTS } from "@/utils/notify";
|
||||
import { EmojiSummaryRecord, GenericVerifiableCredential } from "@/interfaces";
|
||||
import { GiveRecordWithContactInfo } from "@/interfaces/give";
|
||||
import { PromiseTracker } from "@/libs/util";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
ProjectIcon,
|
||||
VueMarkdown,
|
||||
},
|
||||
})
|
||||
export default class ActivityListItem extends Vue {
|
||||
readonly QUICK_EMOJIS = ["👍", "👏", "❤️", "🎉", "😊", "😆", "🔥"];
|
||||
|
||||
@Prop() record!: GiveRecordWithContactInfo;
|
||||
@Prop() lastViewedClaimId?: string;
|
||||
@Prop() isRegistered!: boolean;
|
||||
@Prop() activeDid!: string;
|
||||
@Prop() apiServer!: string;
|
||||
|
||||
isHiddenDid = isHiddenDid;
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
$notify!: NotifyFunction;
|
||||
|
||||
// Emoji-related data
|
||||
showEmojiPicker = false;
|
||||
loadingEmojis = false; // Track if emojis are currently loading
|
||||
|
||||
emojisOnActivity: PromiseTracker<EmojiSummaryRecord[]> | null = null; // load this only when needed
|
||||
|
||||
created() {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
}
|
||||
@@ -303,6 +401,14 @@ export default class ActivityListItem extends Vue {
|
||||
return `${claim?.description || ""}`;
|
||||
}
|
||||
|
||||
get truncatedDescription(): string {
|
||||
const desc = this.description;
|
||||
if (desc.length <= 300) {
|
||||
return desc;
|
||||
}
|
||||
return desc.substring(0, 300) + "...";
|
||||
}
|
||||
|
||||
private displayAmount(code: string, amt: number) {
|
||||
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
|
||||
}
|
||||
@@ -330,5 +436,186 @@ export default class ActivityListItem extends Vue {
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Emoji-related computed properties and methods
|
||||
get hasEmojis(): boolean {
|
||||
return Object.keys(this.record.emojiCount).length > 0;
|
||||
}
|
||||
|
||||
triggerUserEmojiLoad(): PromiseTracker<EmojiSummaryRecord[]> {
|
||||
if (!this.emojisOnActivity) {
|
||||
const promise = new Promise<EmojiSummaryRecord[]>((resolve) => {
|
||||
(async () => {
|
||||
this.axios
|
||||
.get(
|
||||
`${this.apiServer}/api/v2/report/emoji?parentHandleId=${encodeURIComponent(this.record.handleId)}`,
|
||||
{ headers: await getHeaders(this.activeDid) },
|
||||
)
|
||||
.then((response) => {
|
||||
const userEmojiRecords = response.data.data.filter(
|
||||
(e: EmojiSummaryRecord) => e.issuerDid === this.activeDid,
|
||||
);
|
||||
resolve(userEmojiRecords);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Error loading user emojis:", error);
|
||||
resolve([]);
|
||||
});
|
||||
})();
|
||||
});
|
||||
|
||||
this.emojisOnActivity = new PromiseTracker(promise);
|
||||
}
|
||||
return this.emojisOnActivity;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param emoji - The emoji to check.
|
||||
* @returns True if the emoji is in the user's emojis, false otherwise.
|
||||
*
|
||||
* @note This method is quick and synchronous, and can check resolved emojis
|
||||
* without triggering a server request. Returns false if emojis haven't been loaded yet.
|
||||
*/
|
||||
isUserEmojiWithoutLoading(emoji: string): boolean {
|
||||
if (this.emojisOnActivity?.isResolved && this.emojisOnActivity.value) {
|
||||
return this.emojisOnActivity.value.some(
|
||||
(record) => record.text === emoji,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async toggleEmojiPicker() {
|
||||
this.triggerUserEmojiLoad(); // trigger it, but don't wait for it to complete
|
||||
this.showEmojiPicker = !this.showEmojiPicker;
|
||||
}
|
||||
|
||||
async toggleThisEmoji(emoji: string) {
|
||||
// Start loading indicator
|
||||
this.loadingEmojis = true;
|
||||
this.showEmojiPicker = false; // always close the picker when an emoji is clicked
|
||||
|
||||
try {
|
||||
this.triggerUserEmojiLoad(); // trigger just in case
|
||||
|
||||
const userEmojiList = await this.emojisOnActivity!.promise; // must wait now that they've chosen
|
||||
|
||||
const userHasEmoji: boolean = userEmojiList.some(
|
||||
(record) => record.text === emoji,
|
||||
);
|
||||
|
||||
if (userHasEmoji) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Remove Emoji",
|
||||
text: `Do you want to remove your ${emoji} ?`,
|
||||
yesText: "Remove",
|
||||
onYes: async () => {
|
||||
await this.removeEmoji(emoji);
|
||||
},
|
||||
},
|
||||
TIMEOUTS.MODAL,
|
||||
);
|
||||
} else {
|
||||
// User doesn't have this emoji, add it
|
||||
await this.submitEmoji(emoji);
|
||||
}
|
||||
} finally {
|
||||
// Remove loading indicator
|
||||
this.loadingEmojis = false;
|
||||
}
|
||||
}
|
||||
|
||||
async submitEmoji(emoji: string) {
|
||||
try {
|
||||
// Create an Emoji claim and send to the server
|
||||
const emojiClaim: GenericVerifiableCredential = {
|
||||
"@context": "https://endorser.ch",
|
||||
"@type": "Emoji",
|
||||
text: emoji,
|
||||
parentItem: { lastClaimId: this.record.jwtId },
|
||||
};
|
||||
const claim = await createAndSubmitClaim(
|
||||
emojiClaim,
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (claim.success && !claim.embeddedRecordError) {
|
||||
// Update emoji count
|
||||
this.record.emojiCount[emoji] =
|
||||
(this.record.emojiCount[emoji] || 0) + 1;
|
||||
|
||||
// Create a new emoji record (we'll get the actual jwtId from the server response later)
|
||||
const newEmojiRecord: EmojiSummaryRecord = {
|
||||
issuerDid: this.activeDid,
|
||||
jwtId: claim.claimId || "",
|
||||
text: emoji,
|
||||
parentHandleId: this.record.jwtId,
|
||||
};
|
||||
|
||||
// Update user emojis list by creating a new promise with the updated data
|
||||
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
|
||||
this.triggerUserEmojiLoad();
|
||||
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
|
||||
this.emojisOnActivity = new PromiseTracker(
|
||||
Promise.resolve([...currentEmojis, newEmojiRecord]),
|
||||
);
|
||||
} else {
|
||||
this.notify.error("Failed to add emoji.", TIMEOUTS.STANDARD);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error submitting emoji:", error);
|
||||
this.notify.error("Got error adding emoji.", TIMEOUTS.STANDARD);
|
||||
}
|
||||
}
|
||||
|
||||
async removeEmoji(emoji: string) {
|
||||
try {
|
||||
// Create an Emoji claim and send to the server
|
||||
const emojiClaim: GenericVerifiableCredential = {
|
||||
"@context": "https://endorser.ch",
|
||||
"@type": "Emoji",
|
||||
text: emoji,
|
||||
parentItem: { lastClaimId: this.record.jwtId },
|
||||
};
|
||||
const claim = await createAndSubmitClaim(
|
||||
emojiClaim,
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (claim.success && !claim.embeddedRecordError) {
|
||||
// Update emoji count
|
||||
const newCount = Math.max(0, (this.record.emojiCount[emoji] || 0) - 1);
|
||||
if (newCount === 0) {
|
||||
delete this.record.emojiCount[emoji];
|
||||
} else {
|
||||
this.record.emojiCount[emoji] = newCount;
|
||||
}
|
||||
|
||||
// Update user emojis list by creating a new promise with the updated data
|
||||
// (Trigger shouldn't be necessary since all calls should come through a toggle, but just in case someone calls this directly)
|
||||
this.triggerUserEmojiLoad();
|
||||
const currentEmojis = await this.emojisOnActivity!.promise; // must wait now that they've clicked one
|
||||
this.emojisOnActivity = new PromiseTracker(
|
||||
Promise.resolve(
|
||||
currentEmojis.filter(
|
||||
(record) =>
|
||||
record.issuerDid === this.activeDid && record.text !== emoji,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.notify.error("Failed to remove emoji.", TIMEOUTS.STANDARD);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error removing emoji:", error);
|
||||
this.notify.error("Got error removing emoji.", TIMEOUTS.STANDARD);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
465
src/components/BulkMembersDialog.vue
Normal file
465
src/components/BulkMembersDialog.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<div class="text-slate-900 text-center">
|
||||
<h3 class="text-lg font-semibold leading-[1.25] mb-2">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-sm mb-4">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Member Selection Table -->
|
||||
<div class="mb-4">
|
||||
<table
|
||||
class="w-full border-collapse border border-slate-300 text-sm text-start"
|
||||
>
|
||||
<!-- Select All Header -->
|
||||
<thead v-if="membersData && membersData.length > 0">
|
||||
<tr class="bg-slate-100 font-medium">
|
||||
<th class="border border-slate-300 px-3 py-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isIndeterminate"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
Select All
|
||||
</label>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Empty State -->
|
||||
<tr v-if="!membersData || membersData.length === 0">
|
||||
<td
|
||||
class="border border-slate-300 px-3 py-2 text-center italic text-gray-500"
|
||||
>
|
||||
{{ emptyStateText }}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Member Rows -->
|
||||
<tr
|
||||
v-for="member in membersData || []"
|
||||
:key="member.member.memberId"
|
||||
>
|
||||
<td class="border border-slate-300 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isMemberSelected(member.did)"
|
||||
@change="toggleMemberSelection(member.did)"
|
||||
/>
|
||||
<div class="">
|
||||
<div class="text-sm font-semibold">
|
||||
{{ member.name || SOMEONE_UNNAMED }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-xs text-slate-500"
|
||||
>
|
||||
<span class="font-semibold sm:hidden">DID:</span>
|
||||
<span
|
||||
class="w-[35vw] sm:w-auto truncate text-left"
|
||||
style="direction: rtl"
|
||||
>{{ member.did }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Contact indicator - only show if they are already a contact -->
|
||||
<font-awesome
|
||||
v-if="member.isContact"
|
||||
icon="user-circle"
|
||||
class="fa-fw ms-auto text-slate-400 cursor-pointer hover:text-slate-600"
|
||||
@click="showContactInfo"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<!-- Select All Footer -->
|
||||
<tfoot v-if="membersData && membersData.length > 0">
|
||||
<tr class="bg-slate-100 font-medium">
|
||||
<th class="border border-slate-300 px-3 py-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected"
|
||||
:indeterminate="isIndeterminate"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
Select All
|
||||
</label>
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-2">
|
||||
<!-- Main Action Button -->
|
||||
<button
|
||||
v-if="membersData && membersData.length > 0"
|
||||
:disabled="!hasSelectedMembers"
|
||||
:class="[
|
||||
'block w-full text-center text-md font-bold uppercase px-2 py-2 rounded-md',
|
||||
hasSelectedMembers
|
||||
? 'bg-blue-600 text-white cursor-pointer'
|
||||
: 'bg-slate-400 text-slate-200 cursor-not-allowed',
|
||||
]"
|
||||
@click="processSelectedMembers"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
<!-- Cancel Button -->
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { SOMEONE_UNNAMED } from "@/constants/entities";
|
||||
import { MemberData } from "@/interfaces";
|
||||
import { setVisibilityUtil, getHeaders, register } from "@/libs/endorserServer";
|
||||
import { createNotifyHelpers } from "@/utils/notify";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
@Component({
|
||||
mixins: [PlatformServiceMixin],
|
||||
emits: ["close"],
|
||||
})
|
||||
export default class BulkMembersDialog extends Vue {
|
||||
@Prop({ default: "" }) activeDid!: string;
|
||||
@Prop({ default: "" }) apiServer!: string;
|
||||
// isOrganizer: true = organizer mode (admit members), false = member mode (set visibility)
|
||||
@Prop({ required: true }) isOrganizer!: boolean;
|
||||
|
||||
// Vue notification system
|
||||
$notify!: (
|
||||
notification: { group: string; type: string; title: string; text: string },
|
||||
timeout?: number,
|
||||
) => void;
|
||||
|
||||
// Notification system
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
// Component state
|
||||
membersData: MemberData[] = [];
|
||||
selectedMembers: string[] = [];
|
||||
visible = false;
|
||||
|
||||
// Constants
|
||||
// In Vue templates, imported constants need to be explicitly made available to the template
|
||||
readonly SOMEONE_UNNAMED = SOMEONE_UNNAMED;
|
||||
|
||||
get hasSelectedMembers() {
|
||||
return this.selectedMembers.length > 0;
|
||||
}
|
||||
|
||||
get isAllSelected() {
|
||||
if (!this.membersData || this.membersData.length === 0) return false;
|
||||
return this.membersData.every((member) =>
|
||||
this.selectedMembers.includes(member.did),
|
||||
);
|
||||
}
|
||||
|
||||
get isIndeterminate() {
|
||||
if (!this.membersData || this.membersData.length === 0) return false;
|
||||
const selectedCount = this.membersData.filter((member) =>
|
||||
this.selectedMembers.includes(member.did),
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < this.membersData.length;
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.isOrganizer
|
||||
? "Admit Pending Members"
|
||||
: "Add Members to Contacts";
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.isOrganizer
|
||||
? "Would you like to admit these members to the meeting and add them to your contacts?"
|
||||
: "Would you like to add these members to your contacts?";
|
||||
}
|
||||
|
||||
get buttonText() {
|
||||
return this.isOrganizer ? "Admit + Add to Contacts" : "Add to Contacts";
|
||||
}
|
||||
|
||||
get emptyStateText() {
|
||||
return this.isOrganizer
|
||||
? "No pending members to admit"
|
||||
: "No members are not in your contacts";
|
||||
}
|
||||
|
||||
created() {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
}
|
||||
|
||||
open(members: MemberData[]) {
|
||||
this.visible = true;
|
||||
this.membersData = members;
|
||||
// Select all by default
|
||||
this.selectedMembers = this.membersData.map((member) => member.did);
|
||||
}
|
||||
|
||||
close(notSelectedMemberDids: string[]) {
|
||||
this.visible = false;
|
||||
this.$emit("close", { notSelectedMemberDids: notSelectedMemberDids });
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.close(this.membersData.map((member) => member.did));
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
if (!this.membersData || this.membersData.length === 0) return;
|
||||
|
||||
if (this.isAllSelected) {
|
||||
// Deselect all
|
||||
this.selectedMembers = [];
|
||||
} else {
|
||||
// Select all
|
||||
this.selectedMembers = this.membersData.map((member) => member.did);
|
||||
}
|
||||
}
|
||||
|
||||
toggleMemberSelection(memberDid: string) {
|
||||
const index = this.selectedMembers.indexOf(memberDid);
|
||||
if (index > -1) {
|
||||
this.selectedMembers.splice(index, 1);
|
||||
} else {
|
||||
this.selectedMembers.push(memberDid);
|
||||
}
|
||||
}
|
||||
|
||||
isMemberSelected(memberDid: string) {
|
||||
return this.selectedMembers.includes(memberDid);
|
||||
}
|
||||
|
||||
async processSelectedMembers() {
|
||||
try {
|
||||
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
|
||||
this.selectedMembers.includes(member.did),
|
||||
);
|
||||
const notSelectedMembers: MemberData[] = this.membersData.filter(
|
||||
(member) => !this.selectedMembers.includes(member.did),
|
||||
);
|
||||
|
||||
let admittedCount = 0;
|
||||
let contactAddedCount = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const member of selectedMembers) {
|
||||
try {
|
||||
// Organizer mode: admit and register the member first
|
||||
if (this.isOrganizer) {
|
||||
await this.admitMember(member);
|
||||
await this.registerMember(member);
|
||||
admittedCount++;
|
||||
}
|
||||
|
||||
// If they're not a contact yet, add them as a contact
|
||||
if (!member.isContact) {
|
||||
// Organizer mode: set isRegistered to true, member mode: undefined
|
||||
await this.addAsContact(
|
||||
member,
|
||||
this.isOrganizer ? true : undefined,
|
||||
);
|
||||
contactAddedCount++;
|
||||
}
|
||||
|
||||
// Set their seesMe to true
|
||||
await this.updateContactVisibility(member.did, true);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Error processing member ${member.did}:`, error);
|
||||
// Continue with other members even if one fails
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Show success notification
|
||||
if (this.isOrganizer) {
|
||||
if (admittedCount > 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Members Admitted Successfully",
|
||||
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
if (errors > 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to fully admit some members. Work with them individually below.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Member mode: show contacts added notification
|
||||
if (contactAddedCount > 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Contacts Added Successfully",
|
||||
text: `${contactAddedCount} member${contactAddedCount === 1 ? "" : "s"} added as contact${contactAddedCount === 1 ? "" : "s"}.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.close(notSelectedMembers.map((member) => member.did));
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Error ${this.isOrganizer ? "admitting members" : "adding contacts"}:`,
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Some errors occurred. Work with members individually below.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async admitMember(member: {
|
||||
did: string;
|
||||
name: string;
|
||||
member: { memberId: string };
|
||||
}) {
|
||||
try {
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
await this.axios.put(
|
||||
`${this.apiServer}/api/partner/groupOnboardMember/${member.member.memberId}`,
|
||||
{ admitted: true },
|
||||
{ headers },
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error admitting member:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async registerMember(member: MemberData) {
|
||||
try {
|
||||
const contact: Contact = { did: member.did };
|
||||
const result = await register(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
contact,
|
||||
);
|
||||
if (result.success) {
|
||||
if (result.embeddedRecordError) {
|
||||
throw new Error(result.embeddedRecordError);
|
||||
}
|
||||
await this.$updateContact(member.did, { registered: true });
|
||||
} else {
|
||||
throw result;
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error registering member:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async addAsContact(
|
||||
member: { did: string; name: string },
|
||||
isRegistered?: boolean,
|
||||
) {
|
||||
try {
|
||||
const newContact: Contact = {
|
||||
did: member.did,
|
||||
name: member.name,
|
||||
registered: isRegistered,
|
||||
};
|
||||
|
||||
await this.$insertContact(newContact);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error adding contact:", err);
|
||||
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
|
||||
// Contact already exists, continue
|
||||
} else {
|
||||
throw err; // Re-throw if it's not a duplicate error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateContactVisibility(did: string, seesMe: boolean) {
|
||||
try {
|
||||
// Get the contact object
|
||||
const contact = await this.$getContact(did);
|
||||
if (!contact) {
|
||||
throw new Error(`Contact not found for DID: ${did}`);
|
||||
}
|
||||
|
||||
// Use the proper API to set visibility on the server
|
||||
const result = await setVisibilityUtil(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
contact,
|
||||
seesMe,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to set visibility");
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error updating contact visibility:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
showContactInfo() {
|
||||
// isOrganizer: true = admit mode, false = visibility mode
|
||||
const message = this.isOrganizer
|
||||
? "This user is already your contact, but they are not yet admitted to the meeting."
|
||||
: "This user is already your contact, but your activities are not visible to them yet.";
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Info",
|
||||
text: message,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
<span class="text-xs truncate">{{ contact.did }}</span>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div class="text-sm truncate">
|
||||
{{ contact.notes }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,12 @@ messages * - Conditional UI based on platform capabilities * * @component *
|
||||
:to="{ name: 'seed-backup' }"
|
||||
:class="backupButtonClasses"
|
||||
>
|
||||
<!-- Notification dot - show while the user has not yet backed up their seed phrase -->
|
||||
<font-awesome
|
||||
v-if="showRedNotificationDot"
|
||||
icon="circle"
|
||||
class="absolute -right-[8px] -top-[8px] text-rose-500 text-[14px] border border-white rounded-full"
|
||||
></font-awesome>
|
||||
Backup Identifier Seed
|
||||
</router-link>
|
||||
|
||||
@@ -98,6 +104,12 @@ export default class DataExportSection extends Vue {
|
||||
*/
|
||||
isExporting = false;
|
||||
|
||||
/**
|
||||
* Flag indicating if the user has backed up their seed phrase
|
||||
* Used to control the visibility of the notification dot
|
||||
*/
|
||||
showRedNotificationDot = false;
|
||||
|
||||
/**
|
||||
* Notification helper for consistent notification patterns
|
||||
* Created as a getter to ensure $notify is available when called
|
||||
@@ -129,7 +141,7 @@ export default class DataExportSection extends Vue {
|
||||
* CSS classes for the backup button (router link)
|
||||
*/
|
||||
get backupButtonClasses(): string {
|
||||
return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2";
|
||||
return "block relative w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,6 +230,23 @@ export default class DataExportSection extends Vue {
|
||||
|
||||
created() {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
this.loadSeedBackupStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the seed backup status from account settings
|
||||
* Updates the hasBackedUpSeed flag to control notification dot visibility
|
||||
*/
|
||||
private async loadSeedBackupStatus(): Promise<void> {
|
||||
try {
|
||||
const settings = await this.$accountSettings();
|
||||
this.showRedNotificationDot =
|
||||
!!settings.isRegistered && !settings.hasBackedUpSeed;
|
||||
} catch (err: unknown) {
|
||||
logger.error("Failed to load seed backup status:", err);
|
||||
// Default to false (show notification dot) if we can't load the setting
|
||||
this.showRedNotificationDot = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,12 +2,55 @@
|
||||
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
|
||||
projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<ul :class="gridClasses">
|
||||
<!-- Quick Search -->
|
||||
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
|
||||
@input="handleSearchInput"
|
||||
@keydown.enter="performSearch"
|
||||
/>
|
||||
<div
|
||||
v-show="isSearching && searchTerm"
|
||||
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
|
||||
>
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin-pulse leading-[1.1]"
|
||||
></font-awesome>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!searchTerm"
|
||||
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<font-awesome
|
||||
:icon="searchTerm ? 'times' : 'magnifying-glass'"
|
||||
class="fa-fw"
|
||||
></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
|
||||
class="mb-4 text-sm italic text-slate-500 text-center"
|
||||
>
|
||||
“{{ searchTerm }}” doesn't match any
|
||||
{{ entityType === "people" ? "people" : "projects" }}. Try a different
|
||||
search.
|
||||
</div>
|
||||
|
||||
<ul
|
||||
ref="scrollContainer"
|
||||
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
|
||||
>
|
||||
<!-- Special entities (You, Unnamed) for people grids -->
|
||||
<template v-if="entityType === 'people'">
|
||||
<!-- "You" entity -->
|
||||
<SpecialEntityCard
|
||||
v-if="showYouEntity"
|
||||
v-if="showYouEntity && !searchTerm.trim()"
|
||||
entity-type="you"
|
||||
label="You"
|
||||
icon="hand"
|
||||
@@ -21,6 +64,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
|
||||
<!-- "Unnamed" entity -->
|
||||
<SpecialEntityCard
|
||||
v-if="showUnnamedEntity && !searchTerm.trim()"
|
||||
entity-type="unnamed"
|
||||
:label="unnamedEntityName"
|
||||
icon="circle-question"
|
||||
@@ -38,16 +82,60 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
|
||||
<!-- Entity cards (people or projects) -->
|
||||
<template v-if="entityType === 'people'">
|
||||
<PersonCard
|
||||
v-for="person in displayedEntities as Contact[]"
|
||||
:key="person.did"
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
<!-- When showing contacts without search: split into recent and alphabetical -->
|
||||
<template v-if="!searchTerm.trim()">
|
||||
<!-- Recently Added Section -->
|
||||
<template v-if="recentContacts.length > 0">
|
||||
<li
|
||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||
>
|
||||
Recently Added
|
||||
</li>
|
||||
<PersonCard
|
||||
v-for="person in recentContacts"
|
||||
:key="person.did"
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Alphabetical Section -->
|
||||
<template v-if="alphabeticalContacts.length > 0">
|
||||
<li
|
||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||
>
|
||||
Everyone Else
|
||||
</li>
|
||||
<PersonCard
|
||||
v-for="person in alphabeticalContacts"
|
||||
:key="person.did"
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- When searching: show filtered results normally -->
|
||||
<template v-else>
|
||||
<PersonCard
|
||||
v-for="person in displayedEntities as Contact[]"
|
||||
:key="person.did"
|
||||
:person="person"
|
||||
:conflicted="isPersonConflicted(person.did)"
|
||||
:show-time-icon="true"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
@person-selected="handlePersonSelected"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="entityType === 'projects'">
|
||||
@@ -63,28 +151,27 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
||||
@project-selected="handleProjectSelected"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Show All navigation -->
|
||||
<ShowAllCard
|
||||
v-if="shouldShowAll"
|
||||
:entity-type="entityType"
|
||||
:route-name="showAllRoute"
|
||||
:query-params="showAllQueryParams"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
|
||||
import { useInfiniteScroll } from "@vueuse/core";
|
||||
import PersonCard from "./PersonCard.vue";
|
||||
import ProjectCard from "./ProjectCard.vue";
|
||||
import SpecialEntityCard from "./SpecialEntityCard.vue";
|
||||
import ShowAllCard from "./ShowAllCard.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
|
||||
/**
|
||||
* Constants for infinite scroll configuration
|
||||
*/
|
||||
const INITIAL_BATCH_SIZE = 20;
|
||||
const INCREMENT_SIZE = 20;
|
||||
const RECENT_CONTACTS_COUNT = 3;
|
||||
|
||||
/**
|
||||
* EntityGrid - Unified grid layout for displaying people or projects
|
||||
*
|
||||
@@ -93,7 +180,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
* - Special entity integration (You, Unnamed)
|
||||
* - Conflict detection integration
|
||||
* - Empty state messaging
|
||||
* - Show All navigation
|
||||
* - Event delegation for entity selection
|
||||
* - Warning notifications for conflicted entities
|
||||
* - Template streamlined with computed CSS properties
|
||||
@@ -104,7 +190,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||
PersonCard,
|
||||
ProjectCard,
|
||||
SpecialEntityCard,
|
||||
ShowAllCard,
|
||||
},
|
||||
})
|
||||
export default class EntityGrid extends Vue {
|
||||
@@ -112,14 +197,21 @@ export default class EntityGrid extends Vue {
|
||||
@Prop({ required: true })
|
||||
entityType!: "people" | "projects";
|
||||
|
||||
// Search state
|
||||
searchTerm = "";
|
||||
isSearching = false;
|
||||
searchTimeout: NodeJS.Timeout | null = null;
|
||||
filteredEntities: Contact[] | PlanData[] = [];
|
||||
|
||||
// Infinite scroll state
|
||||
displayedCount = INITIAL_BATCH_SIZE;
|
||||
infiniteScrollReset?: () => void;
|
||||
scrollContainer?: HTMLElement;
|
||||
|
||||
/** Array of entities to display */
|
||||
@Prop({ required: true })
|
||||
entities!: Contact[] | PlanData[];
|
||||
|
||||
/** Maximum number of entities to display */
|
||||
@Prop({ default: 10 })
|
||||
maxItems!: number;
|
||||
|
||||
/** Active user's DID */
|
||||
@Prop({ required: true })
|
||||
activeDid!: string;
|
||||
@@ -140,18 +232,14 @@ export default class EntityGrid extends Vue {
|
||||
@Prop({ default: true })
|
||||
showYouEntity!: boolean;
|
||||
|
||||
/** Whether to show the "Unnamed" entity for people grids */
|
||||
@Prop({ default: true })
|
||||
showUnnamedEntity!: boolean;
|
||||
|
||||
/** Whether the "You" entity is selectable */
|
||||
@Prop({ default: true })
|
||||
youSelectable!: boolean;
|
||||
|
||||
/** Route name for "Show All" navigation */
|
||||
@Prop({ default: "" })
|
||||
showAllRoute!: string;
|
||||
|
||||
/** Query parameters for "Show All" navigation */
|
||||
@Prop({ default: () => ({}) })
|
||||
showAllQueryParams!: Record<string, string>;
|
||||
|
||||
/** Notification function from parent component */
|
||||
@Prop()
|
||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||
@@ -160,42 +248,31 @@ export default class EntityGrid extends Vue {
|
||||
@Prop({ default: "other party" })
|
||||
conflictContext!: string;
|
||||
|
||||
/** Whether to hide the "Show All" navigation */
|
||||
@Prop({ default: false })
|
||||
hideShowAll!: boolean;
|
||||
|
||||
/**
|
||||
* Function to determine which entities to display (allows parent control)
|
||||
*
|
||||
* This function prop allows parent components to customize which entities
|
||||
* are displayed in the grid, enabling advanced filtering, sorting, and
|
||||
* display logic beyond the default simple slice behavior.
|
||||
* are displayed in the grid, enabling advanced filtering and sorting.
|
||||
* Note: Infinite scroll is disabled when this prop is provided.
|
||||
*
|
||||
* @param entities - The full array of entities (Contact[] or PlanData[])
|
||||
* @param entityType - The type of entities being displayed ("people" or "projects")
|
||||
* @param maxItems - The maximum number of items to display (from maxItems prop)
|
||||
* @returns Filtered/sorted array of entities to display
|
||||
*
|
||||
* @example
|
||||
* // Custom filtering: only show contacts with profile images
|
||||
* :display-entities-function="(entities, type, max) =>
|
||||
* entities.filter(e => e.profileImageUrl).slice(0, max)"
|
||||
* :display-entities-function="(entities, type) =>
|
||||
* entities.filter(e => e.profileImageUrl)"
|
||||
*
|
||||
* @example
|
||||
* // Custom sorting: sort projects by name
|
||||
* :display-entities-function="(entities, type, max) =>
|
||||
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
|
||||
*
|
||||
* @example
|
||||
* // Advanced logic: different limits for different entity types
|
||||
* :display-entities-function="(entities, type, max) =>
|
||||
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
|
||||
* :display-entities-function="(entities, type) =>
|
||||
* entities.sort((a, b) => a.name.localeCompare(b.name))"
|
||||
*/
|
||||
@Prop({ default: null })
|
||||
displayEntitiesFunction?: (
|
||||
entities: Contact[] | PlanData[],
|
||||
entityType: "people" | "projects",
|
||||
maxItems: number,
|
||||
) => Contact[] | PlanData[];
|
||||
|
||||
/**
|
||||
@@ -206,33 +283,60 @@ export default class EntityGrid extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the grid layout
|
||||
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
|
||||
* When searching, returns filtered results with infinite scroll applied
|
||||
*/
|
||||
get gridClasses(): string {
|
||||
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4";
|
||||
|
||||
if (this.entityType === "projects") {
|
||||
return `${baseClasses} grid-cols-3 md:grid-cols-4`;
|
||||
} else {
|
||||
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
|
||||
get displayedEntities(): Contact[] | PlanData[] {
|
||||
// If searching, return filtered results with infinite scroll
|
||||
if (this.searchTerm.trim()) {
|
||||
return this.filteredEntities.slice(0, this.displayedCount);
|
||||
}
|
||||
|
||||
// If custom function provided, use it (disables infinite scroll)
|
||||
if (this.displayEntitiesFunction) {
|
||||
return this.displayEntitiesFunction(this.entities, this.entityType);
|
||||
}
|
||||
|
||||
// Default: projects use infinite scroll
|
||||
if (this.entityType === "projects") {
|
||||
return (this.entities as PlanData[]).slice(0, this.displayedCount);
|
||||
}
|
||||
|
||||
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed entities to display - uses function prop if provided, otherwise defaults
|
||||
* Get the 3 most recently added contacts (when showing contacts and not searching)
|
||||
*/
|
||||
get displayedEntities(): Contact[] | PlanData[] {
|
||||
if (this.displayEntitiesFunction) {
|
||||
return this.displayEntitiesFunction(
|
||||
this.entities,
|
||||
this.entityType,
|
||||
this.maxItems,
|
||||
);
|
||||
get recentContacts(): Contact[] {
|
||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||
return [];
|
||||
}
|
||||
// Entities are already sorted by date added (newest first)
|
||||
return (this.entities as Contact[]).slice(0, 3);
|
||||
}
|
||||
|
||||
// Default implementation for backward compatibility
|
||||
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems;
|
||||
return this.entities.slice(0, maxDisplay);
|
||||
/**
|
||||
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
|
||||
* Uses infinite scroll to control how many are displayed
|
||||
*/
|
||||
get alphabeticalContacts(): Contact[] {
|
||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||
return [];
|
||||
}
|
||||
// Skip the first 3 (recent contacts) and sort the rest alphabetically
|
||||
// Create a copy to avoid mutating the original array
|
||||
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
||||
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
||||
// Sort alphabetically by name, falling back to DID if name is missing
|
||||
const nameA = (a.name || a.did).toLowerCase();
|
||||
const nameB = (b.name || b.did).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
|
||||
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
||||
return sorted.slice(0, toShow);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,15 +350,6 @@ export default class EntityGrid extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the "Show All" navigation
|
||||
*/
|
||||
get shouldShowAll(): boolean {
|
||||
return (
|
||||
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "You" entity is conflicted
|
||||
*/
|
||||
@@ -328,6 +423,144 @@ export default class EntityGrid extends Vue {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input with debouncing
|
||||
*/
|
||||
handleSearchInput(): void {
|
||||
// Show spinner immediately when user types
|
||||
this.isSearching = true;
|
||||
|
||||
// Clear existing timeout
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout for 500ms delay
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.performSearch();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual search
|
||||
*/
|
||||
async performSearch(): Promise<void> {
|
||||
if (!this.searchTerm.trim()) {
|
||||
this.filteredEntities = [];
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.infiniteScrollReset?.();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSearching = true;
|
||||
|
||||
try {
|
||||
// Simulate async search (in case we need to add API calls later)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||
|
||||
if (this.entityType === "people") {
|
||||
this.filteredEntities = (this.entities as Contact[])
|
||||
.filter((contact: Contact) => {
|
||||
const name = contact.name?.toLowerCase() || "";
|
||||
const did = contact.did.toLowerCase();
|
||||
return name.includes(searchLower) || did.includes(searchLower);
|
||||
})
|
||||
.sort((a: Contact, b: Contact) => {
|
||||
// Sort alphabetically by name, falling back to DID if name is missing
|
||||
const nameA = (a.name || a.did).toLowerCase();
|
||||
const nameB = (b.name || b.did).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
} else {
|
||||
this.filteredEntities = (this.entities as PlanData[])
|
||||
.filter((project: PlanData) => {
|
||||
const name = project.name?.toLowerCase() || "";
|
||||
const handleId = project.handleId.toLowerCase();
|
||||
return name.includes(searchLower) || handleId.includes(searchLower);
|
||||
})
|
||||
.sort((a: PlanData, b: PlanData) => {
|
||||
// Sort alphabetically by name
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
// Reset displayed count when search completes
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.infiniteScrollReset?.();
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search
|
||||
*/
|
||||
clearSearch(): void {
|
||||
this.searchTerm = "";
|
||||
this.filteredEntities = [];
|
||||
this.isSearching = false;
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.infiniteScrollReset?.();
|
||||
|
||||
// Clear any pending timeout
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if more entities can be loaded
|
||||
*/
|
||||
canLoadMore(): boolean {
|
||||
if (this.displayEntitiesFunction) {
|
||||
// Custom function disables infinite scroll
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.searchTerm.trim()) {
|
||||
// Search mode: check filtered entities
|
||||
return this.displayedCount < this.filteredEntities.length;
|
||||
}
|
||||
|
||||
if (this.entityType === "projects") {
|
||||
// Projects: check if more available
|
||||
return this.displayedCount < this.entities.length;
|
||||
}
|
||||
|
||||
// People: check if more alphabetical contacts available
|
||||
// Total available = 3 recent + all alphabetical
|
||||
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
||||
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
|
||||
return this.displayedCount < totalAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize infinite scroll on mount
|
||||
*/
|
||||
mounted(): void {
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.scrollContainer as HTMLElement;
|
||||
|
||||
if (container) {
|
||||
const { reset } = useInfiniteScroll(
|
||||
container,
|
||||
() => {
|
||||
// Load more: increment displayedCount
|
||||
this.displayedCount += INCREMENT_SIZE;
|
||||
},
|
||||
{
|
||||
distance: 50, // pixels from bottom
|
||||
canLoadMore: () => this.canLoadMore(),
|
||||
},
|
||||
);
|
||||
this.infiniteScrollReset = reset;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Emit methods using @Emit decorator
|
||||
|
||||
@Emit("entity-selected")
|
||||
@@ -340,6 +573,33 @@ export default class EntityGrid extends Vue {
|
||||
} {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for changes in search term to reset displayed count
|
||||
*/
|
||||
@Watch("searchTerm")
|
||||
onSearchTermChange(): void {
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.infiniteScrollReset?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for changes in entities prop to reset displayed count
|
||||
*/
|
||||
@Watch("entities")
|
||||
onEntitiesChange(): void {
|
||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||
this.infiniteScrollReset?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup timeouts when component is destroyed
|
||||
*/
|
||||
beforeUnmount(): void {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
|
||||
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
|
||||
based on context * - EntityGrid integration for unified entity display * -
|
||||
Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
|
||||
Show All navigation with context preservation * - Cancel functionality * - Event
|
||||
delegation for entity selection * - Warning notifications for conflicted
|
||||
entities * - Template streamlined with computed CSS properties * * @author
|
||||
Matthew Raymer */
|
||||
Cancel functionality * - Event delegation for entity selection * - Warning
|
||||
notifications for conflicted entities * - Template streamlined with computed CSS
|
||||
properties * * @author Matthew Raymer */
|
||||
<template>
|
||||
<div id="sectionGiftedGiver">
|
||||
<label class="block font-bold mb-4">
|
||||
@@ -16,18 +15,14 @@ Matthew Raymer */
|
||||
<EntityGrid
|
||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||
:entities="shouldShowProjects ? projects : allContacts"
|
||||
:max-items="10"
|
||||
:active-did="activeDid"
|
||||
:all-my-dids="allMyDids"
|
||||
:all-contacts="allContacts"
|
||||
:conflict-checker="conflictChecker"
|
||||
:show-you-entity="shouldShowYouEntity"
|
||||
:you-selectable="youSelectable"
|
||||
:show-all-route="showAllRoute"
|
||||
:show-all-query-params="showAllQueryParams"
|
||||
:notify="notify"
|
||||
:conflict-context="conflictContext"
|
||||
:hide-show-all="hideShowAll"
|
||||
@entity-selected="handleEntitySelected"
|
||||
/>
|
||||
|
||||
@@ -68,7 +63,6 @@ interface EntitySelectionEvent {
|
||||
* - EntityGrid integration for unified entity display
|
||||
* - Conflict detection and prevention
|
||||
* - Special entity handling (You, Unnamed)
|
||||
* - Show All navigation with context preservation
|
||||
* - Cancel functionality
|
||||
* - Event delegation for entity selection
|
||||
* - Warning notifications for conflicted entities
|
||||
@@ -154,10 +148,6 @@ export default class EntitySelectionStep extends Vue {
|
||||
@Prop()
|
||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
/** Whether to hide the "Show All" navigation */
|
||||
@Prop({ default: false })
|
||||
hideShowAll!: boolean;
|
||||
|
||||
/**
|
||||
* CSS classes for the cancel button
|
||||
*/
|
||||
@@ -222,59 +212,6 @@ export default class EntitySelectionStep extends Vue {
|
||||
return !this.conflictChecker(this.activeDid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route name for "Show All" navigation
|
||||
*/
|
||||
get showAllRoute(): string {
|
||||
if (this.shouldShowProjects) {
|
||||
return "discover";
|
||||
} else if (this.allContacts.length > 0) {
|
||||
return "contact-gift";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for "Show All" navigation
|
||||
*/
|
||||
get showAllQueryParams(): Record<string, string> {
|
||||
const baseParams = {
|
||||
stepType: this.stepType,
|
||||
giverEntityType: this.giverEntityType,
|
||||
recipientEntityType: this.recipientEntityType,
|
||||
// Form field values to preserve
|
||||
description: this.description,
|
||||
amountInput: this.amountInput,
|
||||
unitCode: this.unitCode,
|
||||
offerId: this.offerId,
|
||||
fromProjectId: this.fromProjectId,
|
||||
toProjectId: this.toProjectId,
|
||||
showProjects: this.showProjects.toString(),
|
||||
isFromProjectView: this.isFromProjectView.toString(),
|
||||
};
|
||||
|
||||
if (this.shouldShowProjects) {
|
||||
// For project contexts, still pass entity type information
|
||||
return baseParams;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseParams,
|
||||
// Always pass both giver and recipient info for context preservation
|
||||
giverProjectId: this.fromProjectId || "",
|
||||
giverProjectName: this.giver?.name || "",
|
||||
giverProjectImage: this.giver?.image || "",
|
||||
giverProjectHandleId: this.giver?.handleId || "",
|
||||
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
|
||||
recipientProjectId: this.toProjectId || "",
|
||||
recipientProjectName: this.receiver?.name || "",
|
||||
recipientProjectImage: this.receiver?.image || "",
|
||||
recipientProjectHandleId: this.receiver?.handleId || "",
|
||||
recipientDid:
|
||||
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle entity selection from EntityGrid
|
||||
*/
|
||||
|
||||
@@ -211,8 +211,6 @@ export default class FeedFilters extends Vue {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#dialogFeedFilters.dialog-overlay {
|
||||
overflow: scroll;
|
||||
}
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
:unit-code="unitCode"
|
||||
:offer-id="offerId"
|
||||
:notify="$notify"
|
||||
:hide-show-all="hideShowAll"
|
||||
@entity-selected="handleEntitySelected"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
@@ -82,6 +81,7 @@ import GiftDetailsStep from "../components/GiftDetailsStep.vue";
|
||||
import { PlanData } from "../interfaces/records";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify";
|
||||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||
import {
|
||||
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
|
||||
NOTIFY_GIFT_ERROR_NO_DESCRIPTION,
|
||||
@@ -116,7 +116,6 @@ export default class GiftedDialog extends Vue {
|
||||
@Prop() fromProjectId = "";
|
||||
@Prop() toProjectId = "";
|
||||
@Prop() isFromProjectView = false;
|
||||
@Prop() hideShowAll = false;
|
||||
@Prop({ default: "person" }) giverEntityType = "person" as
|
||||
| "person"
|
||||
| "project";
|
||||
@@ -219,11 +218,20 @@ export default class GiftedDialog extends Vue {
|
||||
this.stepType = "giver";
|
||||
|
||||
try {
|
||||
const settings = await this.$settings();
|
||||
const settings = await this.$accountSettings();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
this.allContacts = await this.$contacts();
|
||||
// Get activeDid from active_identity table (single source of truth)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
|
||||
logger.debug("[GiftedDialog] Settings received:", {
|
||||
activeDid: this.activeDid,
|
||||
apiServer: this.apiServer,
|
||||
});
|
||||
|
||||
this.allContacts = await this.$contactsByDateAdded();
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
@@ -411,6 +419,15 @@ export default class GiftedDialog extends Vue {
|
||||
);
|
||||
} else {
|
||||
this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG);
|
||||
|
||||
// Show seed phrase backup reminder if needed
|
||||
try {
|
||||
const settings = await this.$accountSettings();
|
||||
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
|
||||
} catch (error) {
|
||||
logger.error("Error checking seed backup status:", error);
|
||||
}
|
||||
|
||||
if (this.callbackOnSuccess) {
|
||||
this.callbackOnSuccess(amount);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
If you'd like an introduction,
|
||||
<a
|
||||
class="text-blue-500"
|
||||
@click="copyToClipboard('A link to this page', deepLinkUrl)"
|
||||
@click="copyTextToClipboard('A link to this page', deepLinkUrl)"
|
||||
>click here to copy this page, paste it into a message, and ask if
|
||||
they'll tell you more about the {{ roleName }}.</a
|
||||
>
|
||||
@@ -110,7 +110,7 @@
|
||||
* @since 2024-12-19
|
||||
*/
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { copyToClipboard } from "../services/ClipboardService";
|
||||
import * as R from "ramda";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
@@ -197,19 +197,24 @@ export default class HiddenDidDialog extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
copyToClipboard(name: string, text: string) {
|
||||
useClipboard()
|
||||
.copy(text)
|
||||
.then(() => {
|
||||
this.notify.success(
|
||||
NOTIFY_COPIED_TO_CLIPBOARD.message(name || "That"),
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
});
|
||||
async copyTextToClipboard(name: string, text: string) {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
this.notify.success(
|
||||
NOTIFY_COPIED_TO_CLIPBOARD.message(name || "That"),
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
} catch (error) {
|
||||
this.$logAndConsole(
|
||||
`Error copying ${name || "content"} to clipboard: ${error}`,
|
||||
true,
|
||||
);
|
||||
this.notify.error(`Failed to copy ${name || "content"} to clipboard.`);
|
||||
}
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.deepLinkUrl);
|
||||
this.copyTextToClipboard("A link to this page", this.deepLinkUrl);
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
v-if="shouldMirrorVideo"
|
||||
class="absolute top-2 left-2 bg-black/50 text-white px-2 py-1 rounded text-xs"
|
||||
>
|
||||
<font-awesome icon="mirror" class="w-[1em] mr-1" />
|
||||
<font-awesome icon="circle-user" class="w-[1em] mr-1" />
|
||||
Mirrored
|
||||
</div>
|
||||
<div :class="cameraControlsClasses">
|
||||
@@ -293,7 +293,7 @@ const inputImageFileNameRef = ref<Blob>();
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
$notify!: NotifyFunction;
|
||||
$router!: Router;
|
||||
notify = createNotifyHelpers(this.$notify);
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
/** Active DID for user authentication */
|
||||
activeDid = "";
|
||||
@@ -498,9 +498,14 @@ export default class ImageMethodDialog extends Vue {
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
// Initialize notification helpers
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
try {
|
||||
const settings = await this.$accountSettings();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
// Get activeDid from active_identity table (single source of truth)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
this.notify.error(
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
:weight="2"
|
||||
color="#3b82f6"
|
||||
fill-color="#3b82f6"
|
||||
fill-opacity="0.2"
|
||||
:fill-opacity="0.2"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
|
||||
@@ -1,183 +1,255 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
|
||||
>
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
|
||||
<div v-else>
|
||||
<div class="text-center text-red-600 py-4">
|
||||
{{ decryptionErrorMessage() }}
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
|
||||
>
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||
</div>
|
||||
|
||||
<div v-if="missingMyself" class="py-4 text-red-600">
|
||||
You are not currently admitted by the organizer.
|
||||
</div>
|
||||
<div v-if="!firstName" class="py-4 text-red-600">
|
||||
Your name is not set, so others may not recognize you. Reload this page
|
||||
to set it.
|
||||
</div>
|
||||
<!-- Members List -->
|
||||
|
||||
<div>
|
||||
<span
|
||||
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
|
||||
class="inline-flex items-center flex-wrap"
|
||||
>
|
||||
<span class="inline-flex items-center">
|
||||
• Click
|
||||
<span
|
||||
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
|
||||
>
|
||||
<font-awesome icon="plus" class="text-sm" />
|
||||
</span>
|
||||
/
|
||||
<span
|
||||
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
|
||||
>
|
||||
<font-awesome icon="minus" class="text-sm" />
|
||||
</span>
|
||||
to add/remove them to/from the meeting.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
v-if="membersToShow().length > 0"
|
||||
class="inline-flex items-center"
|
||||
>
|
||||
• Click
|
||||
<span
|
||||
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
|
||||
<div v-else>
|
||||
<div class="text-center text-red-600 my-4">
|
||||
{{ decryptionErrorMessage() }}
|
||||
</div>
|
||||
|
||||
<div v-if="missingMyself" class="py-4 text-red-600">
|
||||
You are not currently admitted by the organizer.
|
||||
</div>
|
||||
<div v-if="!firstName" class="py-4 text-red-600">
|
||||
Your name is not set, so others may not recognize you. Reload this
|
||||
page to set it.
|
||||
</div>
|
||||
|
||||
<ul class="list-disc text-sm ps-4 space-y-2 mb-4">
|
||||
<li
|
||||
v-if="
|
||||
membersToShow().length > 0 && showOrganizerTools && isOrganizer
|
||||
"
|
||||
>
|
||||
<font-awesome icon="circle-user" class="text-xl" />
|
||||
</span>
|
||||
to add them to your contacts.
|
||||
</span>
|
||||
</div>
|
||||
Click
|
||||
<font-awesome icon="circle-plus" class="text-blue-500 text-sm" />
|
||||
/
|
||||
<font-awesome icon="circle-minus" class="text-rose-500 text-sm" />
|
||||
to add/remove them to/from the meeting.
|
||||
</li>
|
||||
<li
|
||||
v-if="
|
||||
membersToShow().length > 0 && getNonContactMembers().length > 0
|
||||
"
|
||||
>
|
||||
Click
|
||||
<font-awesome icon="circle-user" class="text-green-600 text-sm" />
|
||||
to add them to your contacts.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<!--
|
||||
<div class="flex justify-between">
|
||||
<!--
|
||||
always have at least one refresh button even without members in case the organizer
|
||||
changes the password
|
||||
-->
|
||||
<button
|
||||
class="btn-action-refresh"
|
||||
title="Refresh members list"
|
||||
@click="fetchMembers"
|
||||
>
|
||||
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="member in membersToShow()"
|
||||
:key="member.member.memberId"
|
||||
class="mt-2 p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ member.name || unnamedMember }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="!getContactFor(member.did) && member.did !== activeDid"
|
||||
class="flex justify-end"
|
||||
>
|
||||
<button
|
||||
class="btn-add-contact"
|
||||
title="Add as contact"
|
||||
@click="addAsContact(member)"
|
||||
>
|
||||
<font-awesome icon="circle-user" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="member.did !== activeDid"
|
||||
class="btn-info-contact"
|
||||
title="Contact info"
|
||||
@click="
|
||||
informAboutAddingContact(
|
||||
getContactFor(member.did) !== undefined,
|
||||
)
|
||||
"
|
||||
>
|
||||
<font-awesome icon="circle-info" class="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="
|
||||
showOrganizerTools && isOrganizer && member.did !== activeDid
|
||||
"
|
||||
class="flex items-center"
|
||||
>
|
||||
<button
|
||||
class="btn-admission"
|
||||
:title="
|
||||
member.member.admitted ? 'Remove member' : 'Admit member'
|
||||
"
|
||||
@click="checkWhetherContactBeforeAdmitting(member)"
|
||||
>
|
||||
<font-awesome
|
||||
:icon="member.member.admitted ? 'minus' : 'plus'"
|
||||
class="text-sm"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="btn-info-admission"
|
||||
title="Admission info"
|
||||
@click="informAboutAdmission()"
|
||||
>
|
||||
<font-awesome icon="circle-info" class="text-base" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
title="Refresh members list now"
|
||||
@click="refreshData(false)"
|
||||
>
|
||||
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
|
||||
Refresh
|
||||
<span class="text-xs">({{ countdownTimer }}s)</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 truncate">
|
||||
{{ member.did }}
|
||||
<ul
|
||||
v-if="membersToShow().length > 0"
|
||||
class="border-t border-slate-300 my-2"
|
||||
>
|
||||
<li
|
||||
v-for="member in membersToShow()"
|
||||
:key="member.member.memberId"
|
||||
:class="[
|
||||
'border-b px-2 sm:px-3 py-1.5',
|
||||
{
|
||||
'bg-blue-50 border-t border-blue-300 -mt-[1px]':
|
||||
!member.member.admitted &&
|
||||
(isOrganizer || member.did === activeDid),
|
||||
},
|
||||
{ 'border-slate-300': member.member.admitted },
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<div class="flex items-center gap-1 overflow-hidden">
|
||||
<h3
|
||||
:class="[
|
||||
'font-semibold truncate',
|
||||
{
|
||||
'text-slate-500':
|
||||
!member.member.admitted &&
|
||||
(isOrganizer || member.did === activeDid),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<font-awesome
|
||||
v-if="member.member.memberId === members[0]?.memberId"
|
||||
icon="crown"
|
||||
class="fa-fw text-amber-400"
|
||||
/>
|
||||
<font-awesome
|
||||
v-if="member.did === activeDid"
|
||||
icon="hand"
|
||||
class="fa-fw text-slate-500"
|
||||
/>
|
||||
<font-awesome
|
||||
v-if="
|
||||
!member.member.admitted &&
|
||||
(isOrganizer || member.did === activeDid)
|
||||
"
|
||||
icon="hourglass-half"
|
||||
class="fa-fw text-slate-400"
|
||||
/>
|
||||
{{ member.name || unnamedMember }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="!getContactFor(member.did) && member.did !== activeDid"
|
||||
class="flex items-center gap-1.5 ml-2 ms-1"
|
||||
>
|
||||
<button
|
||||
class="btn-add-contact ml-2"
|
||||
title="Add as contact"
|
||||
@click="addAsContact(member)"
|
||||
>
|
||||
<font-awesome icon="circle-user" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn-info-contact ml-2"
|
||||
title="Contact Info"
|
||||
@click="
|
||||
informAboutAddingContact(
|
||||
getContactFor(member.did) !== undefined,
|
||||
)
|
||||
"
|
||||
>
|
||||
<font-awesome icon="circle-info" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="getContactFor(member.did) && member.did !== activeDid"
|
||||
class="flex items-center gap-1.5 ms-1"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'contact-edit', params: { did: member.did } }"
|
||||
>
|
||||
<font-awesome
|
||||
icon="pen"
|
||||
class="text-sm text-blue-500 ml-2 mb-1"
|
||||
/>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{ name: 'did', params: { did: member.did } }"
|
||||
>
|
||||
<font-awesome
|
||||
icon="arrow-up-right-from-square"
|
||||
class="text-sm text-blue-500 ml-2 mb-1"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="
|
||||
showOrganizerTools && isOrganizer && member.did !== activeDid
|
||||
"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<button
|
||||
:class="
|
||||
member.member.admitted
|
||||
? 'btn-admission-remove'
|
||||
: 'btn-admission-add'
|
||||
"
|
||||
:title="
|
||||
member.member.admitted ? 'Remove member' : 'Admit member'
|
||||
"
|
||||
@click="checkWhetherContactBeforeAdmitting(member)"
|
||||
>
|
||||
<font-awesome
|
||||
:icon="
|
||||
member.member.admitted ? 'circle-minus' : 'circle-plus'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn-info-admission"
|
||||
title="Admission Info"
|
||||
@click="informAboutAdmission()"
|
||||
>
|
||||
<font-awesome icon="circle-info" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 truncate">
|
||||
{{ member.did }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="membersToShow().length > 0" class="flex justify-between">
|
||||
<!--
|
||||
always have at least one refresh button even without members in case the organizer
|
||||
changes the password
|
||||
-->
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
title="Refresh members list now"
|
||||
@click="refreshData(false)"
|
||||
>
|
||||
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
|
||||
Refresh
|
||||
<span class="text-xs">({{ countdownTimer }}s)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="members.length === 0" class="text-gray-500 py-4">
|
||||
No members have joined this meeting yet
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
|
||||
<button
|
||||
class="btn-action-refresh"
|
||||
title="Refresh members list"
|
||||
@click="fetchMembers"
|
||||
>
|
||||
<font-awesome icon="rotate" :class="{ 'fa-spin': isLoading }" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="members.length === 0" class="text-gray-500 py-4">
|
||||
No members have joined this meeting yet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Members Dialog for both admitting and setting visibility -->
|
||||
<BulkMembersDialog
|
||||
ref="bulkMembersDialog"
|
||||
:active-did="activeDid"
|
||||
:api-server="apiServer"
|
||||
:is-organizer="isOrganizer"
|
||||
@close="closeBulkMembersDialogCallback"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||
|
||||
import {
|
||||
errorStringForLog,
|
||||
getHeaders,
|
||||
register,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import { decryptMessage } from "../libs/crypto";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { NotificationIface } from "@/constants/app";
|
||||
import {
|
||||
NOTIFY_ADD_CONTACT_FIRST,
|
||||
NOTIFY_CONTINUE_WITHOUT_ADDING,
|
||||
} from "@/constants/notifications";
|
||||
import { SOMEONE_UNNAMED } from "@/constants/entities";
|
||||
import {
|
||||
errorStringForLog,
|
||||
getHeaders,
|
||||
register,
|
||||
serverMessageForUser,
|
||||
} from "@/libs/endorserServer";
|
||||
import { decryptMessage } from "@/libs/crypto";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MemberData } from "@/interfaces";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import BulkMembersDialog from "./BulkMembersDialog.vue";
|
||||
|
||||
interface Member {
|
||||
admitted: boolean;
|
||||
@@ -193,13 +265,15 @@ interface DecryptedMember {
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
BulkMembersDialog,
|
||||
},
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class MembersList extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
libsUtil = libsUtil;
|
||||
|
||||
@Prop({ required: true }) password!: string;
|
||||
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||
@@ -210,6 +284,7 @@ export default class MembersList extends Vue {
|
||||
return message;
|
||||
}
|
||||
|
||||
contacts: Array<Contact> = [];
|
||||
decryptedMembers: DecryptedMember[] = [];
|
||||
firstName = "";
|
||||
isLoading = true;
|
||||
@@ -219,7 +294,12 @@ export default class MembersList extends Vue {
|
||||
missingMyself = false;
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contacts: Array<Contact> = [];
|
||||
|
||||
// Auto-refresh functionality
|
||||
countdownTimer = 10;
|
||||
autoRefreshInterval: NodeJS.Timeout | null = null;
|
||||
lastRefreshTime = 0;
|
||||
previousMemberDidsIgnored: string[] = [];
|
||||
|
||||
/**
|
||||
* Get the unnamed member constant
|
||||
@@ -232,11 +312,16 @@ export default class MembersList extends Vue {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
const settings = await this.$accountSettings();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
// Get activeDid from active_identity table (single source of truth)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.firstName = settings.firstName || "";
|
||||
await this.fetchMembers();
|
||||
await this.loadContacts();
|
||||
|
||||
this.refreshData();
|
||||
}
|
||||
|
||||
async fetchMembers() {
|
||||
@@ -282,7 +367,10 @@ export default class MembersList extends Vue {
|
||||
const content = JSON.parse(decryptedContent);
|
||||
|
||||
this.decryptedMembers.push({
|
||||
member: member,
|
||||
member: {
|
||||
...member,
|
||||
admitted: member.admitted !== undefined ? member.admitted : true, // Default to true for non-organizers
|
||||
},
|
||||
name: content.name,
|
||||
did: content.did,
|
||||
isRegistered: !!content.isRegistered,
|
||||
@@ -324,22 +412,81 @@ export default class MembersList extends Vue {
|
||||
}
|
||||
|
||||
membersToShow(): DecryptedMember[] {
|
||||
let members: DecryptedMember[] = [];
|
||||
|
||||
if (this.isOrganizer) {
|
||||
if (this.showOrganizerTools) {
|
||||
return this.decryptedMembers;
|
||||
members = this.decryptedMembers;
|
||||
} else {
|
||||
return this.decryptedMembers.filter(
|
||||
members = this.decryptedMembers.filter(
|
||||
(member: DecryptedMember) => member.member.admitted,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// non-organizers only get visible members from server, plus themselves
|
||||
|
||||
// Check if current user is already in the decrypted members list
|
||||
if (
|
||||
!this.decryptedMembers.find((member) => member.did === this.activeDid)
|
||||
) {
|
||||
// this is a stub for this user just in case they are waiting to get in
|
||||
// which is especially useful so they can see their own DID
|
||||
const currentUser: DecryptedMember = {
|
||||
member: {
|
||||
admitted: false,
|
||||
content: "{}",
|
||||
memberId: -1,
|
||||
},
|
||||
name: this.firstName,
|
||||
did: this.activeDid,
|
||||
isRegistered: false,
|
||||
};
|
||||
members = [currentUser, ...this.decryptedMembers];
|
||||
} else {
|
||||
members = this.decryptedMembers;
|
||||
}
|
||||
}
|
||||
// non-organizers only get visible members from server
|
||||
return this.decryptedMembers;
|
||||
|
||||
// Sort members according to priority:
|
||||
// 1. Organizer at the top
|
||||
// 2. Current user next
|
||||
// 3. Non-admitted members next
|
||||
// 4. Everyone else after
|
||||
return members.sort((a, b) => {
|
||||
// Check if either member is the organizer (first member in original list)
|
||||
const aIsOrganizer = a.member.memberId === this.members[0]?.memberId;
|
||||
const bIsOrganizer = b.member.memberId === this.members[0]?.memberId;
|
||||
|
||||
// Check if either member is the current user
|
||||
const aIsCurrentUser = a.did === this.activeDid;
|
||||
const bIsCurrentUser = b.did === this.activeDid;
|
||||
|
||||
// Organizer always comes first
|
||||
if (aIsOrganizer && !bIsOrganizer) return -1;
|
||||
if (!aIsOrganizer && bIsOrganizer) return 1;
|
||||
|
||||
// If both are organizers, maintain original order
|
||||
if (aIsOrganizer && bIsOrganizer) return 0;
|
||||
|
||||
// Current user comes second (after organizer)
|
||||
if (aIsCurrentUser && !bIsCurrentUser && !bIsOrganizer) return -1;
|
||||
if (!aIsCurrentUser && bIsCurrentUser && !aIsOrganizer) return 1;
|
||||
|
||||
// If both are current users, maintain original order
|
||||
if (aIsCurrentUser && bIsCurrentUser) return 0;
|
||||
|
||||
// Non-admitted members come before admitted members
|
||||
if (!a.member.admitted && b.member.admitted) return -1;
|
||||
if (a.member.admitted && !b.member.admitted) return 1;
|
||||
|
||||
// If admission status is the same, maintain original order
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
informAboutAdmission() {
|
||||
this.notify.info(
|
||||
"This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
|
||||
"This is to register people in Time Safari and to admit them to the meeting. A (+) symbol means they are not yet admitted and you can register and admit them. A (-) symbol means you can remove them, but they will stay registered.",
|
||||
TIMEOUTS.VERY_LONG,
|
||||
);
|
||||
}
|
||||
@@ -358,18 +505,85 @@ export default class MembersList extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async loadContacts() {
|
||||
this.contacts = await this.$getAllContacts();
|
||||
}
|
||||
|
||||
getContactFor(did: string): Contact | undefined {
|
||||
return this.contacts.find((contact) => contact.did === did);
|
||||
}
|
||||
|
||||
getPendingMembersToAdmit(): MemberData[] {
|
||||
return this.decryptedMembers
|
||||
.filter(
|
||||
(member) => member.did !== this.activeDid && !member.member.admitted,
|
||||
)
|
||||
.map(this.convertDecryptedMemberToMemberData);
|
||||
}
|
||||
|
||||
getNonContactMembers(): MemberData[] {
|
||||
return this.decryptedMembers
|
||||
.filter(
|
||||
(member) =>
|
||||
member.did !== this.activeDid && !this.getContactFor(member.did),
|
||||
)
|
||||
.map(this.convertDecryptedMemberToMemberData);
|
||||
}
|
||||
|
||||
convertDecryptedMemberToMemberData(
|
||||
decryptedMember: DecryptedMember,
|
||||
): MemberData {
|
||||
return {
|
||||
did: decryptedMember.did,
|
||||
name: decryptedMember.name,
|
||||
isContact: !!this.getContactFor(decryptedMember.did),
|
||||
member: {
|
||||
memberId: decryptedMember.member.memberId.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the bulk members dialog if conditions are met
|
||||
* (admit pending members for organizers, add to contacts for non-organizers)
|
||||
*/
|
||||
async refreshData(bypassPromptIfAllWereIgnored = true) {
|
||||
// Force refresh both contacts and members
|
||||
this.contacts = await this.$getAllContacts();
|
||||
await this.fetchMembers();
|
||||
|
||||
const pendingMembers = this.isOrganizer
|
||||
? this.getPendingMembersToAdmit()
|
||||
: this.getNonContactMembers();
|
||||
if (pendingMembers.length === 0) {
|
||||
this.startAutoRefresh();
|
||||
return;
|
||||
}
|
||||
if (bypassPromptIfAllWereIgnored) {
|
||||
// only show if there are members that have not been ignored
|
||||
const pendingMembersNotIgnored = pendingMembers.filter(
|
||||
(member) => !this.previousMemberDidsIgnored.includes(member.did),
|
||||
);
|
||||
if (pendingMembersNotIgnored.length === 0) {
|
||||
this.startAutoRefresh();
|
||||
// everyone waiting has been ignored
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.stopAutoRefresh();
|
||||
(this.$refs.bulkMembersDialog as BulkMembersDialog).open(pendingMembers);
|
||||
}
|
||||
|
||||
// Bulk Members Dialog methods
|
||||
async closeBulkMembersDialogCallback(
|
||||
result: { notSelectedMemberDids: string[] } | undefined,
|
||||
) {
|
||||
this.previousMemberDidsIgnored = result?.notSelectedMemberDids || [];
|
||||
|
||||
await this.refreshData();
|
||||
}
|
||||
|
||||
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
|
||||
const contact = this.getContactFor(decrMember.did);
|
||||
if (!decrMember.member.admitted && !contact) {
|
||||
// If not a contact, show confirmation dialog
|
||||
// If not a contact, stop auto-refresh and show confirmation dialog
|
||||
this.stopAutoRefresh();
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
@@ -382,6 +596,7 @@ export default class MembersList extends Vue {
|
||||
await this.addAsContact(decrMember);
|
||||
// After adding as contact, proceed with admission
|
||||
await this.toggleAdmission(decrMember);
|
||||
this.startAutoRefresh();
|
||||
},
|
||||
onNo: async () => {
|
||||
// If they choose not to add as contact, show second confirmation
|
||||
@@ -394,14 +609,19 @@ export default class MembersList extends Vue {
|
||||
yesText: NOTIFY_CONTINUE_WITHOUT_ADDING.yesText,
|
||||
onYes: async () => {
|
||||
await this.toggleAdmission(decrMember);
|
||||
this.startAutoRefresh();
|
||||
},
|
||||
onCancel: async () => {
|
||||
// Do nothing, effectively canceling the operation
|
||||
this.startAutoRefresh();
|
||||
},
|
||||
},
|
||||
TIMEOUTS.MODAL,
|
||||
);
|
||||
},
|
||||
onCancel: async () => {
|
||||
this.startAutoRefresh();
|
||||
},
|
||||
},
|
||||
TIMEOUTS.MODAL,
|
||||
);
|
||||
@@ -503,6 +723,41 @@ export default class MembersList extends Vue {
|
||||
this.notify.error(message, TIMEOUTS.LONG);
|
||||
}
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
this.stopAutoRefresh();
|
||||
this.lastRefreshTime = Date.now();
|
||||
this.countdownTimer = 10;
|
||||
|
||||
this.autoRefreshInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = (now - this.lastRefreshTime) / 1000;
|
||||
|
||||
if (timeSinceLastRefresh >= 10) {
|
||||
// Time to refresh
|
||||
this.refreshData();
|
||||
this.lastRefreshTime = now;
|
||||
this.countdownTimer = 10;
|
||||
} else {
|
||||
// Update countdown
|
||||
this.countdownTimer = Math.max(
|
||||
0,
|
||||
Math.round(10 - timeSinceLastRefresh),
|
||||
);
|
||||
}
|
||||
}, 1000); // Update every second
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
this.autoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -517,29 +772,26 @@ export default class MembersList extends Vue {
|
||||
|
||||
.btn-add-contact {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply ml-2 w-8 h-8 flex items-center justify-center rounded-full
|
||||
bg-green-100 text-green-600 hover:bg-green-200 hover:text-green-800
|
||||
transition-colors;
|
||||
}
|
||||
|
||||
.btn-info-contact {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply ml-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full
|
||||
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800
|
||||
transition-colors;
|
||||
}
|
||||
|
||||
.btn-admission {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply mr-2 w-6 h-6 flex items-center justify-center rounded-full
|
||||
bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800
|
||||
@apply text-lg text-green-600 hover:text-green-800
|
||||
transition-colors;
|
||||
}
|
||||
|
||||
.btn-info-contact,
|
||||
.btn-info-admission {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply mr-2 mb-2 w-6 h-6 flex items-center justify-center rounded-full
|
||||
bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-800
|
||||
@apply text-slate-400 hover:text-slate-600
|
||||
transition-colors;
|
||||
}
|
||||
|
||||
.btn-admission-add {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply text-lg text-blue-500 hover:text-blue-700
|
||||
transition-colors;
|
||||
}
|
||||
|
||||
.btn-admission-remove {
|
||||
/* stylelint-disable-next-line at-rule-no-unknown */
|
||||
@apply text-lg text-rose-500 hover:text-rose-700
|
||||
transition-colors;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,6 +64,7 @@ import * as libsUtil from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||
import {
|
||||
NOTIFY_OFFER_SETTINGS_ERROR,
|
||||
NOTIFY_OFFER_RECORDING,
|
||||
@@ -175,7 +176,11 @@ export default class OfferDialog extends Vue {
|
||||
|
||||
const settings = await this.$accountSettings();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
// Get activeDid from active_identity table (single source of truth)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
@@ -299,6 +304,14 @@ export default class OfferDialog extends Vue {
|
||||
);
|
||||
} else {
|
||||
this.notify.success(NOTIFY_OFFER_SUCCESS.message, TIMEOUTS.VERY_LONG);
|
||||
|
||||
// Show seed phrase backup reminder if needed
|
||||
try {
|
||||
const settings = await this.$accountSettings();
|
||||
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
|
||||
} catch (error) {
|
||||
logger.error("Error checking seed backup status:", error);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -270,7 +270,12 @@ export default class OnboardingDialog extends Vue {
|
||||
async open(page: OnboardPage) {
|
||||
this.page = page;
|
||||
const settings = await this.$accountSettings();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
// Get activeDid from active_identity table (single source of truth)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
|
||||
const contacts = await this.$getAllContacts();
|
||||
|
||||
@@ -3,30 +3,25 @@ GiftedDialog.vue to handle person entity display * with selection states and
|
||||
conflict detection. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<li :class="cardClasses" @click="handleClick">
|
||||
<div class="relative w-fit mx-auto">
|
||||
<div>
|
||||
<EntityIcon
|
||||
v-if="person.did"
|
||||
:contact="person"
|
||||
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
|
||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||
/>
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="circle-question"
|
||||
class="text-slate-400 text-5xl mb-1"
|
||||
class="text-slate-400 text-5xl mb-1 shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Time icon overlay for contacts -->
|
||||
<div
|
||||
v-if="person.did && showTimeIcon"
|
||||
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
|
||||
>
|
||||
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 :class="nameClasses">
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
<div class="overflow-hidden">
|
||||
<h3 :class="nameClasses">
|
||||
{{ displayName }}
|
||||
</h3>
|
||||
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -81,29 +76,32 @@ export default class PersonCard extends Vue {
|
||||
* Computed CSS classes for the card
|
||||
*/
|
||||
get cardClasses(): string {
|
||||
const baseCardClasses =
|
||||
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
|
||||
|
||||
if (!this.selectable || this.conflicted) {
|
||||
return "opacity-50 cursor-not-allowed";
|
||||
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
|
||||
}
|
||||
return "cursor-pointer hover:bg-slate-50";
|
||||
|
||||
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the person name
|
||||
*/
|
||||
get nameClasses(): string {
|
||||
const baseClasses =
|
||||
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
|
||||
const baseNameClasses = "text-sm font-semibold truncate";
|
||||
|
||||
if (this.conflicted) {
|
||||
return `${baseClasses} text-slate-400`;
|
||||
return `${baseNameClasses} text-slate-500`;
|
||||
}
|
||||
|
||||
// Add italic styling for entities without set names
|
||||
if (!this.person.name) {
|
||||
return `${baseClasses} italic text-slate-500`;
|
||||
return `${baseNameClasses} italic text-slate-500`;
|
||||
}
|
||||
|
||||
return baseClasses;
|
||||
return baseNameClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -268,7 +268,12 @@ export default class PhotoDialog extends Vue {
|
||||
// logger.log("PhotoDialog mounted");
|
||||
try {
|
||||
const settings = await this.$accountSettings();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
|
||||
// Get activeDid from active_identity table (single source of truth)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||
this.activeDid = activeIdentity.activeDid || "";
|
||||
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
logger.log("isRegistered:", this.isRegistered);
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -2,25 +2,26 @@
|
||||
GiftedDialog.vue to handle project entity display * with selection states and
|
||||
issuer information. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<li class="cursor-pointer" @click="handleClick">
|
||||
<div class="relative w-fit mx-auto">
|
||||
<ProjectIcon
|
||||
:entity-id="project.handleId"
|
||||
:icon-size="48"
|
||||
:image-url="project.image"
|
||||
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1"
|
||||
/>
|
||||
</div>
|
||||
<li
|
||||
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
|
||||
@click="handleClick"
|
||||
>
|
||||
<ProjectIcon
|
||||
:entity-id="project.handleId"
|
||||
:icon-size="48"
|
||||
:image-url="project.image"
|
||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||
/>
|
||||
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ project.name || unnamedProject }}
|
||||
</h3>
|
||||
<div class="overflow-hidden">
|
||||
<h3 class="text-sm font-semibold truncate">
|
||||
{{ project.name || unnamedProject }}
|
||||
</h3>
|
||||
|
||||
<div class="text-xs text-slate-500 truncate">
|
||||
<font-awesome icon="user" class="fa-fw text-slate-400" />
|
||||
{{ issuerDisplayName }}
|
||||
<div class="text-xs text-slate-500 truncate">
|
||||
<font-awesome icon="user" class="text-slate-400" />
|
||||
{{ issuerDisplayName }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
|
||||
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
|
||||
entity types. * * @author Matthew Raymer */
|
||||
<template>
|
||||
<li class="cursor-pointer">
|
||||
<router-link :to="navigationRoute" class="block text-center">
|
||||
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
|
||||
<h3
|
||||
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Show All
|
||||
</h3>
|
||||
</router-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationRaw } from "vue-router";
|
||||
|
||||
/**
|
||||
* ShowAllCard - Displays "Show All" navigation for entity grids
|
||||
*
|
||||
* Features:
|
||||
* - Provides navigation to full entity listings
|
||||
* - Supports different routes based on entity type
|
||||
* - Maintains context through query parameters
|
||||
* - Consistent visual styling with other cards
|
||||
*/
|
||||
@Component({ name: "ShowAllCard" })
|
||||
export default class ShowAllCard extends Vue {
|
||||
/** Type of entities being shown */
|
||||
@Prop({ required: true })
|
||||
entityType!: "people" | "projects";
|
||||
|
||||
/** Route name to navigate to */
|
||||
@Prop({ required: true })
|
||||
routeName!: string;
|
||||
|
||||
/** Query parameters to pass to the route */
|
||||
@Prop({ default: () => ({}) })
|
||||
queryParams!: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Computed navigation route with query parameters
|
||||
*/
|
||||
get navigationRoute(): RouteLocationRaw {
|
||||
return {
|
||||
name: this.routeName,
|
||||
query: this.queryParams,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure router-link styling is consistent */
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover .fa-circle-right {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -63,23 +63,24 @@ export default class SpecialEntityCard extends Vue {
|
||||
conflictContext!: string;
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the card container
|
||||
* Computed CSS classes for the card
|
||||
*/
|
||||
get cardClasses(): string {
|
||||
const baseClasses = "block";
|
||||
const baseCardClasses =
|
||||
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
|
||||
|
||||
if (!this.selectable || this.conflicted) {
|
||||
return `${baseClasses} cursor-not-allowed opacity-50`;
|
||||
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
|
||||
}
|
||||
|
||||
return `${baseClasses} cursor-pointer`;
|
||||
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed CSS classes for the icon
|
||||
*/
|
||||
get iconClasses(): string {
|
||||
const baseClasses = "text-5xl mb-1";
|
||||
const baseClasses = "text-[2rem]";
|
||||
|
||||
if (this.conflicted) {
|
||||
return `${baseClasses} text-slate-400`;
|
||||
@@ -101,7 +102,7 @@ export default class SpecialEntityCard extends Vue {
|
||||
*/
|
||||
get nameClasses(): string {
|
||||
const baseClasses =
|
||||
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
|
||||
"text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
|
||||
|
||||
if (this.conflicted) {
|
||||
return `${baseClasses} text-slate-400`;
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))]"
|
||||
v-if="message"
|
||||
class="-mt-6 bg-rose-100 border border-t-0 border-dashed border-rose-600 text-rose-900 text-sm text-center font-semibold rounded-b-md px-3 py-2 mb-3"
|
||||
>
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
:to="{ name: 'help' }"
|
||||
class="text-xs uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md ml-1"
|
||||
>
|
||||
Help
|
||||
</router-link>
|
||||
</span>
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,14 +13,15 @@ import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class TopMessage extends Vue {
|
||||
// Enhanced PlatformServiceMixin v4.0 provides:
|
||||
// - Cached database operations: this.$contacts(), this.$settings(), this.$accountSettings()
|
||||
// - Settings shortcuts: this.$saveSettings(), this.$saveMySettings()
|
||||
// - Cached database operations: this.$contacts(), this.$accountSettings()
|
||||
// - Settings shortcuts: this.$saveSettings()
|
||||
// - Cache management: this.$refreshSettings(), this.$clearAllCaches()
|
||||
// - Ultra-concise database methods: this.$db(), this.$exec(), this.$query()
|
||||
// - All methods use smart caching with TTL for massive performance gains
|
||||
@@ -44,26 +38,52 @@ export default class TopMessage extends Vue {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
|
||||
try {
|
||||
// Ultra-concise cached settings loading - replaces 50+ lines of logic!
|
||||
const settings = await this.$accountSettings(undefined, {
|
||||
activeDid: undefined,
|
||||
apiServer: AppString.PROD_ENDORSER_API_SERVER,
|
||||
// Load settings without overriding database values - fixes settings inconsistency
|
||||
logger.debug("[TopMessage] 📥 Loading settings without overrides...");
|
||||
const settings = await this.$accountSettings();
|
||||
|
||||
// Get activeDid from new active_identity table (ActiveDid migration)
|
||||
const activeIdentity = await this.$getActiveIdentity();
|
||||
|
||||
logger.debug("[TopMessage] 📊 Settings loaded:", {
|
||||
activeDid: activeIdentity.activeDid,
|
||||
apiServer: settings.apiServer,
|
||||
warnIfTestServer: settings.warnIfTestServer,
|
||||
warnIfProdServer: settings.warnIfProdServer,
|
||||
component: "TopMessage",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Only show warnings if the user has explicitly enabled them
|
||||
if (
|
||||
settings.warnIfTestServer &&
|
||||
settings.apiServer &&
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
const didPrefix = activeIdentity.activeDid?.slice(11, 15);
|
||||
this.message = "You're not using prod, user " + didPrefix;
|
||||
logger.debug("[TopMessage] ⚠️ Test server warning displayed:", {
|
||||
apiServer: settings.apiServer,
|
||||
didPrefix: didPrefix,
|
||||
});
|
||||
} else if (
|
||||
settings.warnIfProdServer &&
|
||||
settings.apiServer &&
|
||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
const didPrefix = activeIdentity.activeDid?.slice(11, 15);
|
||||
this.message = "You are using prod, user " + didPrefix;
|
||||
logger.debug("[TopMessage] ⚠️ Production server warning displayed:", {
|
||||
apiServer: settings.apiServer,
|
||||
didPrefix: didPrefix,
|
||||
});
|
||||
} else {
|
||||
logger.debug(
|
||||
"[TopMessage] ℹ️ No warnings displayed - conditions not met",
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error("[TopMessage] ❌ Error loading settings:", err);
|
||||
this.notify.error(JSON.stringify(err), TIMEOUTS.MODAL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<!-- show spinner if loading limits -->
|
||||
<div
|
||||
v-if="loadingLimits"
|
||||
class="text-center"
|
||||
class="text-slate-500 text-center italic mb-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
@@ -19,7 +19,10 @@
|
||||
aria-hidden="true"
|
||||
></font-awesome>
|
||||
</div>
|
||||
<div class="mb-4 text-center">
|
||||
<div
|
||||
v-if="limitsMessage"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
|
||||
>
|
||||
{{ limitsMessage }}
|
||||
</div>
|
||||
<div v-if="endorserLimits">
|
||||
|
||||
@@ -84,7 +84,7 @@ export default class UserNameDialog extends Vue {
|
||||
*/
|
||||
async open(aCallback?: (name?: string) => void) {
|
||||
this.callback = aCallback || this.callback;
|
||||
const settings = await this.$settings();
|
||||
const settings = await this.$accountSettings();
|
||||
this.givenName = settings.firstName || "";
|
||||
this.visible = true;
|
||||
}
|
||||
@@ -95,7 +95,18 @@ export default class UserNameDialog extends Vue {
|
||||
*/
|
||||
async onClickSaveChanges() {
|
||||
try {
|
||||
await this.$updateSettings({ firstName: this.givenName });
|
||||
// Get activeDid from new active_identity table (ActiveDid migration)
|
||||
const activeIdentity = await this.$getActiveIdentity();
|
||||
const activeDid = activeIdentity.activeDid;
|
||||
|
||||
if (activeDid) {
|
||||
// Save to user-specific settings for the current identity
|
||||
await this.$saveUserSettings(activeDid, { firstName: this.givenName });
|
||||
} else {
|
||||
// Fallback to master settings if no active DID
|
||||
await this.$saveSettings({ firstName: this.givenName });
|
||||
}
|
||||
|
||||
this.visible = false;
|
||||
this.callback(this.givenName);
|
||||
} catch (error) {
|
||||
|
||||
781
src/components/notifications/DailyNotificationSection.vue
Normal file
781
src/components/notifications/DailyNotificationSection.vue
Normal file
@@ -0,0 +1,781 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="notificationsSupported"
|
||||
id="sectionDailyNotifications"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
aria-labelledby="dailyNotificationsHeading"
|
||||
>
|
||||
<h2 id="dailyNotificationsHeading" class="mb-2 font-bold">
|
||||
Daily Notifications
|
||||
<button
|
||||
class="text-slate-400 fa-fw cursor-pointer"
|
||||
aria-label="Learn more about native notifications"
|
||||
@click.stop="showNativeNotificationInfo"
|
||||
>
|
||||
<font-awesome icon="circle-question" aria-hidden="true" />
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>Daily Notification</div>
|
||||
<!-- Toggle switch -->
|
||||
<div
|
||||
class="relative ml-2 cursor-pointer"
|
||||
role="switch"
|
||||
:aria-checked="nativeNotificationEnabled"
|
||||
:aria-label="
|
||||
nativeNotificationEnabled
|
||||
? 'Disable daily notifications'
|
||||
: 'Enable daily notifications'
|
||||
"
|
||||
tabindex="0"
|
||||
@click="toggleNativeNotification"
|
||||
>
|
||||
<!-- input -->
|
||||
<input
|
||||
:checked="nativeNotificationEnabled"
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
tabindex="-1"
|
||||
readonly
|
||||
/>
|
||||
<!-- line -->
|
||||
<div
|
||||
class="block bg-slate-500 w-14 h-8 rounded-full transition"
|
||||
:class="{
|
||||
'bg-blue-600': nativeNotificationEnabled,
|
||||
}"
|
||||
></div>
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
:class="{
|
||||
'left-7 bg-white': nativeNotificationEnabled,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show "Open Settings" button when permissions are denied -->
|
||||
<div
|
||||
v-if="
|
||||
notificationsSupported &&
|
||||
notificationStatus &&
|
||||
notificationStatus.permissions.notifications === 'denied'
|
||||
"
|
||||
class="mt-2"
|
||||
>
|
||||
<button
|
||||
class="w-full px-3 py-2 bg-blue-600 text-white rounded text-sm font-medium"
|
||||
@click="openNotificationSettings"
|
||||
>
|
||||
Open Settings
|
||||
</button>
|
||||
<p class="text-xs text-slate-500 mt-1 text-center">
|
||||
Enable notifications in Settings > App info > Notifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Time input section - show when enabled OR when no time is set -->
|
||||
<div
|
||||
v-if="nativeNotificationEnabled || !nativeNotificationTimeStorage"
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
v-if="nativeNotificationEnabled"
|
||||
class="flex items-center justify-between mb-2"
|
||||
>
|
||||
<span
|
||||
>Scheduled for:
|
||||
<span v-if="nativeNotificationTime">{{
|
||||
nativeNotificationTime
|
||||
}}</span>
|
||||
<span v-else class="text-slate-500">Not set</span></span
|
||||
>
|
||||
<button
|
||||
class="text-blue-500 text-sm"
|
||||
@click="editNativeNotificationTime"
|
||||
>
|
||||
{{ showTimeEdit ? "Cancel" : "Edit Time" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time input (shown when editing or when no time is set) -->
|
||||
<div v-if="showTimeEdit || !nativeNotificationTimeStorage" class="mt-2">
|
||||
<label class="block text-sm text-slate-600 mb-1">
|
||||
Notification Time
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="nativeNotificationTimeStorage"
|
||||
type="time"
|
||||
class="rounded border border-slate-400 px-2 py-2"
|
||||
@change="onTimeChange"
|
||||
/>
|
||||
<button
|
||||
v-if="showTimeEdit || nativeNotificationTimeStorage"
|
||||
class="px-3 py-2 bg-blue-600 text-white rounded"
|
||||
@click="saveTimeChange"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="!nativeNotificationTimeStorage"
|
||||
class="text-xs text-slate-500 mt-1"
|
||||
>
|
||||
Set a time before enabling notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="mt-2 text-sm text-slate-500">Loading...</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* DailyNotificationSection Component
|
||||
*
|
||||
* A self-contained component for managing daily notification scheduling
|
||||
* in AccountViewView. This component handles platform detection, permission
|
||||
* requests, scheduling, and state management for daily notifications.
|
||||
*
|
||||
* Features:
|
||||
* - Platform capability detection (hides on unsupported platforms)
|
||||
* - Permission request flow
|
||||
* - Schedule/cancel notifications
|
||||
* - Time editing with HTML5 time input
|
||||
* - Settings persistence
|
||||
* - Plugin state synchronization
|
||||
*
|
||||
* @author Generated for TimeSafari Daily Notification Integration
|
||||
* @component
|
||||
*/
|
||||
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import type {
|
||||
NotificationStatus,
|
||||
PermissionStatus,
|
||||
} from "@/services/PlatformService";
|
||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||
import type { NotificationIface } from "@/constants/app";
|
||||
|
||||
/**
|
||||
* Convert 24-hour time format ("09:00") to 12-hour display format ("9:00 AM")
|
||||
*/
|
||||
function formatTimeForDisplay(time24: string): string {
|
||||
if (!time24) return "";
|
||||
const [hours, minutes] = time24.split(":");
|
||||
const hourNum = parseInt(hours);
|
||||
const isPM = hourNum >= 12;
|
||||
const displayHour =
|
||||
hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
return `${displayHour}:${minutes} ${isPM ? "PM" : "AM"}`;
|
||||
}
|
||||
|
||||
@Component({
|
||||
name: "DailyNotificationSection",
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class DailyNotificationSection extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
// Component state
|
||||
notificationsSupported: boolean = false;
|
||||
nativeNotificationEnabled: boolean = false;
|
||||
nativeNotificationTime: string = ""; // Display format: "9:00 AM"
|
||||
nativeNotificationTimeStorage: string = ""; // Plugin format: "09:00"
|
||||
nativeNotificationTitle: string = "Daily Update";
|
||||
nativeNotificationMessage: string = "Your daily notification is ready!";
|
||||
showTimeEdit: boolean = false;
|
||||
loading: boolean = false;
|
||||
notificationStatus: NotificationStatus | null = null;
|
||||
|
||||
// Notify helpers
|
||||
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||
|
||||
async created(): Promise<void> {
|
||||
this.notify = createNotifyHelpers(this.$notify);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize component state on mount
|
||||
* Checks platform support and syncs with plugin state
|
||||
*
|
||||
* **Token Refresh on Mount:**
|
||||
* - Refreshes native fetcher configuration to ensure plugin has valid token
|
||||
* - This handles cases where app was closed for extended periods
|
||||
* - Token refresh happens automatically without user interaction
|
||||
*
|
||||
* **App Resume Listener:**
|
||||
* - Listens for Capacitor 'resume' event to refresh token when app comes to foreground
|
||||
* - Ensures plugin always has fresh token for background prefetch operations
|
||||
* - Cleaned up in `beforeDestroy()` lifecycle hook
|
||||
*/
|
||||
async mounted(): Promise<void> {
|
||||
await this.initializeState();
|
||||
// Refresh native fetcher configuration on mount
|
||||
// This ensures plugin has valid token even if app was closed for extended periods
|
||||
await this.refreshNativeFetcherConfig();
|
||||
// Listen for app resume events to refresh token when app comes to foreground
|
||||
// This is part of the proactive token refresh strategy
|
||||
document.addEventListener("resume", this.handleAppResume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on component destroy
|
||||
*/
|
||||
beforeDestroy(): void {
|
||||
document.removeEventListener("resume", this.handleAppResume);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app resume event - refresh native fetcher configuration
|
||||
*
|
||||
* This method is called when the app comes to foreground (via Capacitor 'resume' event).
|
||||
* It proactively refreshes the JWT token to ensure the plugin has valid authentication
|
||||
* for background prefetch operations.
|
||||
*
|
||||
* **Why refresh on resume?**
|
||||
* - Tokens expire after 72 hours
|
||||
* - App may have been closed for extended periods
|
||||
* - Refreshing ensures plugin has valid token for next prefetch cycle
|
||||
* - No user interaction required - happens automatically
|
||||
*
|
||||
* @see {@link refreshNativeFetcherConfig} For implementation details
|
||||
*/
|
||||
async handleAppResume(): Promise<void> {
|
||||
logger.debug(
|
||||
"[DailyNotificationSection] App resumed, refreshing native fetcher config",
|
||||
);
|
||||
await this.refreshNativeFetcherConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh native fetcher configuration with fresh JWT token
|
||||
*
|
||||
* This method ensures the daily notification plugin has a valid authentication token
|
||||
* for background prefetch operations. It's called proactively to prevent token expiration
|
||||
* issues during offline periods.
|
||||
*
|
||||
* **Refresh Triggers:**
|
||||
* - Component mount (when notification settings page loads)
|
||||
* - App resume (when app comes to foreground)
|
||||
* - Notification enabled (when user enables daily notifications)
|
||||
*
|
||||
* **Token Refresh Strategy (Hybrid Approach):**
|
||||
* - Tokens are valid for 72 hours (see `accessTokenForBackground`)
|
||||
* - Tokens are refreshed proactively when app is already open
|
||||
* - If token expires while offline, plugin uses cached content
|
||||
* - Next time app opens, token is automatically refreshed
|
||||
*
|
||||
* **Why This Approach?**
|
||||
* - No app wake-up required (tokens refresh when app is already open)
|
||||
* - Works offline (72-hour validity supports extended offline periods)
|
||||
* - Automatic (no user interaction required)
|
||||
* - Includes starred plans (fetcher receives user's starred plans for prefetch)
|
||||
* - Graceful degradation (if refresh fails, cached content still works)
|
||||
*
|
||||
* **Error Handling:**
|
||||
* - Errors are logged but not shown to user (background operation)
|
||||
* - Returns early if notifications not supported or disabled
|
||||
* - Returns early if API server not configured
|
||||
* - Failures don't interrupt user experience
|
||||
*
|
||||
* @returns Promise that resolves when refresh completes (or fails silently)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Called automatically on mount/resume
|
||||
* await this.refreshNativeFetcherConfig();
|
||||
* ```
|
||||
*
|
||||
* @see {@link CapacitorPlatformService.configureNativeFetcher} For token generation
|
||||
* @see {@link accessTokenForBackground} For 72-hour token generation
|
||||
*/
|
||||
async refreshNativeFetcherConfig(): Promise<void> {
|
||||
try {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// Early return: Only refresh if notifications are supported and enabled
|
||||
// This prevents unnecessary work when notifications aren't being used
|
||||
if (!this.notificationsSupported || !this.nativeNotificationEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get settings for API server and starred plans
|
||||
// API server tells plugin where to fetch content from
|
||||
// Starred plans tell plugin which plans to prefetch
|
||||
const settings = await this.$accountSettings();
|
||||
const apiServer = settings.apiServer || "";
|
||||
|
||||
if (!apiServer) {
|
||||
logger.warn(
|
||||
"[DailyNotificationSection] No API server configured, skipping native fetcher refresh",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get starred plans from settings
|
||||
// These are passed to the plugin so it knows which plans to prefetch
|
||||
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||
|
||||
// Configure native fetcher with fresh token
|
||||
// The jwt parameter is ignored - configureNativeFetcher generates it automatically
|
||||
// This ensures we always have a fresh token with current expiration time
|
||||
await platformService.configureNativeFetcher({
|
||||
apiServer,
|
||||
jwt: "", // Will be generated automatically by configureNativeFetcher
|
||||
starredPlanHandleIds,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[DailyNotificationSection] Native fetcher configuration refreshed",
|
||||
);
|
||||
} catch (error) {
|
||||
// Don't show error to user - this is a background operation
|
||||
// Failures are logged for debugging but don't interrupt user experience
|
||||
// If refresh fails, plugin will use existing token (if still valid) or cached content
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Failed to refresh native fetcher config:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize component state
|
||||
* Checks platform support and syncs with plugin state
|
||||
*/
|
||||
async initializeState(): Promise<void> {
|
||||
try {
|
||||
this.loading = true;
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
logger.debug(
|
||||
"[DailyNotificationSection] Checking notification support...",
|
||||
);
|
||||
|
||||
// Check if notifications are supported on this platform
|
||||
// This also verifies plugin availability (returns null if plugin unavailable)
|
||||
const status = await platformService.getDailyNotificationStatus();
|
||||
if (status === null) {
|
||||
// Notifications not supported or plugin unavailable - don't initialize
|
||||
this.notificationsSupported = false;
|
||||
logger.warn(
|
||||
"[DailyNotificationSection] Notifications not supported or plugin unavailable - section will be hidden",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"[DailyNotificationSection] Notifications supported, status:",
|
||||
status,
|
||||
);
|
||||
|
||||
this.notificationsSupported = true;
|
||||
this.notificationStatus = status;
|
||||
|
||||
// Plugin state is the source of truth
|
||||
if (status.isScheduled && status.scheduledTime) {
|
||||
// Plugin has a scheduled notification - sync UI to match
|
||||
this.nativeNotificationEnabled = true;
|
||||
this.nativeNotificationTimeStorage = status.scheduledTime;
|
||||
this.nativeNotificationTime = formatTimeForDisplay(
|
||||
status.scheduledTime,
|
||||
);
|
||||
} else {
|
||||
// No plugin schedule - UI defaults to disabled
|
||||
this.nativeNotificationEnabled = false;
|
||||
this.nativeNotificationTimeStorage = "";
|
||||
this.nativeNotificationTime = "";
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[DailyNotificationSection] Failed to initialize:", error);
|
||||
this.notificationsSupported = false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle notification on/off
|
||||
*/
|
||||
async toggleNativeNotification(): Promise<void> {
|
||||
// Prevent multiple simultaneous toggles
|
||||
if (this.loading) {
|
||||
logger.warn(
|
||||
"[DailyNotificationSection] Toggle ignored - operation in progress",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[DailyNotificationSection] Toggling notification: ${this.nativeNotificationEnabled} -> ${!this.nativeNotificationEnabled}`,
|
||||
);
|
||||
|
||||
if (this.nativeNotificationEnabled) {
|
||||
await this.disableNativeNotification();
|
||||
} else {
|
||||
await this.enableNativeNotification();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable daily notification
|
||||
*/
|
||||
async enableNativeNotification(): Promise<void> {
|
||||
try {
|
||||
this.loading = true;
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// Check if we have a time set
|
||||
if (!this.nativeNotificationTimeStorage) {
|
||||
this.notify.error(
|
||||
"Please set a notification time first",
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check permissions first - this also verifies plugin availability
|
||||
let permissions: PermissionStatus | null;
|
||||
try {
|
||||
permissions = await platformService.checkNotificationPermissions();
|
||||
logger.info(
|
||||
`[DailyNotificationSection] Permission check result:`,
|
||||
permissions,
|
||||
);
|
||||
} catch (error) {
|
||||
// Plugin may not be available or there's an error
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Failed to check permissions (plugin may be unavailable):",
|
||||
error,
|
||||
);
|
||||
this.notify.error(
|
||||
"Unable to check notification permissions. The notification plugin may not be installed.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissions === null) {
|
||||
// Platform doesn't support notifications or plugin unavailable
|
||||
logger.warn(
|
||||
"[DailyNotificationSection] Notifications not supported or plugin unavailable",
|
||||
);
|
||||
this.notify.error(
|
||||
"Notifications are not supported on this platform or the plugin is not installed.",
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[DailyNotificationSection] Permission state: ${permissions.notifications}`,
|
||||
);
|
||||
|
||||
// If permissions are explicitly denied, don't try to request again
|
||||
// (this prevents the plugin crash when handling denied permissions)
|
||||
// Android won't show the dialog again if permissions are permanently denied
|
||||
if (permissions.notifications === "denied") {
|
||||
logger.warn(
|
||||
"[DailyNotificationSection] Permissions already denied, directing user to settings",
|
||||
);
|
||||
this.notify.error(
|
||||
"Notification permissions were denied. Tap 'Open Settings' to enable them.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only request if permissions are in "prompt" state (not denied, not granted)
|
||||
// This ensures we only call requestPermissions when Android will actually show a dialog
|
||||
if (permissions.notifications === "prompt") {
|
||||
logger.info(
|
||||
"[DailyNotificationSection] Permission state is 'prompt', requesting permissions...",
|
||||
);
|
||||
try {
|
||||
const result = await platformService.requestNotificationPermissions();
|
||||
logger.info(
|
||||
`[DailyNotificationSection] Permission request result:`,
|
||||
result,
|
||||
);
|
||||
if (result === null) {
|
||||
// Plugin unavailable or request failed
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Permission request returned null",
|
||||
);
|
||||
this.notify.error(
|
||||
"Unable to request notification permissions. The plugin may not be available.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
return;
|
||||
}
|
||||
if (!result.notifications) {
|
||||
// Permission request was denied
|
||||
logger.warn(
|
||||
"[DailyNotificationSection] Permission request denied by user",
|
||||
);
|
||||
this.notify.error(
|
||||
"Notification permissions are required. Tap 'Open Settings' to enable them.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
return;
|
||||
}
|
||||
// Permissions granted - continue
|
||||
logger.info(
|
||||
"[DailyNotificationSection] Permissions granted successfully",
|
||||
);
|
||||
} catch (error) {
|
||||
// Handle permission request errors (including plugin crashes)
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Permission request failed:",
|
||||
error,
|
||||
);
|
||||
this.notify.error(
|
||||
"Unable to request notification permissions. Tap 'Open Settings' to enable them.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
return;
|
||||
}
|
||||
} else if (permissions.notifications !== "granted") {
|
||||
// Unexpected state - shouldn't happen, but handle gracefully
|
||||
logger.warn(
|
||||
`[DailyNotificationSection] Unexpected permission state: ${permissions.notifications}`,
|
||||
);
|
||||
this.notify.error(
|
||||
"Unable to determine notification permission status. Tap 'Open Settings' to check.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
return;
|
||||
} else {
|
||||
logger.info("[DailyNotificationSection] Permissions already granted");
|
||||
}
|
||||
|
||||
// Permissions are granted - continue with scheduling
|
||||
|
||||
// Schedule notification via PlatformService
|
||||
await platformService.scheduleDailyNotification({
|
||||
time: this.nativeNotificationTimeStorage, // "09:00" in local time
|
||||
title: this.nativeNotificationTitle,
|
||||
body: this.nativeNotificationMessage,
|
||||
sound: true,
|
||||
priority: "high",
|
||||
});
|
||||
|
||||
// Update UI state
|
||||
this.nativeNotificationEnabled = true;
|
||||
|
||||
// Refresh native fetcher configuration with fresh token
|
||||
// This ensures plugin has valid authentication when notifications are first enabled
|
||||
// Token will be valid for 72 hours, supporting offline prefetch operations
|
||||
await this.refreshNativeFetcherConfig();
|
||||
|
||||
this.notify.success(
|
||||
"Daily notification scheduled successfully",
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Failed to enable notification:",
|
||||
error,
|
||||
);
|
||||
this.notify.error(
|
||||
"Failed to schedule notification. Please try again.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
this.nativeNotificationEnabled = false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable daily notification
|
||||
*/
|
||||
async disableNativeNotification(): Promise<void> {
|
||||
try {
|
||||
this.loading = true;
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// Cancel notification via PlatformService
|
||||
await platformService.cancelDailyNotification();
|
||||
|
||||
// Update UI state
|
||||
this.nativeNotificationEnabled = false;
|
||||
this.nativeNotificationTime = "";
|
||||
this.nativeNotificationTimeStorage = "";
|
||||
this.showTimeEdit = false;
|
||||
|
||||
this.notify.success("Daily notification disabled", TIMEOUTS.SHORT);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Failed to disable notification:",
|
||||
error,
|
||||
);
|
||||
this.notify.error(
|
||||
"Failed to disable notification. Please try again.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide time edit input
|
||||
*/
|
||||
editNativeNotificationTime(): void {
|
||||
this.showTimeEdit = !this.showTimeEdit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle time input change
|
||||
*/
|
||||
onTimeChange(): void {
|
||||
// Time is already in nativeNotificationTimeStorage via v-model
|
||||
// Just update display format
|
||||
if (this.nativeNotificationTimeStorage) {
|
||||
this.nativeNotificationTime = formatTimeForDisplay(
|
||||
this.nativeNotificationTimeStorage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save time change and update notification schedule
|
||||
*/
|
||||
async saveTimeChange(): Promise<void> {
|
||||
if (!this.nativeNotificationTimeStorage) {
|
||||
this.notify.error("Please select a time", TIMEOUTS.SHORT);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update display format
|
||||
this.nativeNotificationTime = formatTimeForDisplay(
|
||||
this.nativeNotificationTimeStorage,
|
||||
);
|
||||
|
||||
// If notification is enabled, update the schedule
|
||||
if (this.nativeNotificationEnabled) {
|
||||
await this.updateNotificationTime(this.nativeNotificationTimeStorage);
|
||||
} else {
|
||||
// Just update local state (time preference stored in component)
|
||||
this.showTimeEdit = false;
|
||||
this.notify.success("Notification time saved", TIMEOUTS.SHORT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification time
|
||||
* If notification is enabled, immediately updates the schedule
|
||||
*/
|
||||
async updateNotificationTime(newTime: string): Promise<void> {
|
||||
// newTime is in "HH:mm" format from HTML5 time input
|
||||
if (!this.nativeNotificationEnabled) {
|
||||
// If notification is disabled, just update local state
|
||||
this.nativeNotificationTimeStorage = newTime;
|
||||
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
||||
this.showTimeEdit = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Notification is enabled - update the schedule
|
||||
try {
|
||||
this.loading = true;
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
|
||||
// 1. Cancel existing notification
|
||||
await platformService.cancelDailyNotification();
|
||||
|
||||
// 2. Schedule with new time
|
||||
await platformService.scheduleDailyNotification({
|
||||
time: newTime, // "09:00" in local time
|
||||
title: this.nativeNotificationTitle,
|
||||
body: this.nativeNotificationMessage,
|
||||
sound: true,
|
||||
priority: "high",
|
||||
});
|
||||
|
||||
// 3. Update local state
|
||||
this.nativeNotificationTimeStorage = newTime;
|
||||
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
||||
|
||||
this.notify.success(
|
||||
"Notification time updated successfully",
|
||||
TIMEOUTS.SHORT,
|
||||
);
|
||||
this.showTimeEdit = false;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Failed to update notification time:",
|
||||
error,
|
||||
);
|
||||
this.notify.error(
|
||||
"Failed to update notification time. Please try again.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info dialog about native notifications
|
||||
*/
|
||||
showNativeNotificationInfo(): void {
|
||||
// TODO: Implement info dialog or navigate to help page
|
||||
this.notify.info(
|
||||
"Daily notifications use your device's native notification system. They work even when the app is closed.",
|
||||
TIMEOUTS.STANDARD,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open app notification settings
|
||||
*/
|
||||
async openNotificationSettings(): Promise<void> {
|
||||
try {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const result = await platformService.openAppNotificationSettings();
|
||||
if (result === null) {
|
||||
this.notify.error(
|
||||
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
} else {
|
||||
this.notify.success("Opening notification settings...", TIMEOUTS.SHORT);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[DailyNotificationSection] Failed to open notification settings:",
|
||||
error,
|
||||
);
|
||||
this.notify.error(
|
||||
"Unable to open settings. Please go to Settings > Apps > TimeSafari > Notifications manually.",
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dot {
|
||||
transition: left 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -86,7 +86,7 @@ export const ACCOUNT_VIEW_CONSTANTS = {
|
||||
CANNOT_UPLOAD_IMAGES: "You cannot upload images.",
|
||||
BAD_SERVER_RESPONSE: "Bad server response.",
|
||||
ERROR_RETRIEVING_LIMITS:
|
||||
"No limits were found, so no actions are allowed. You will need to get registered.",
|
||||
"No limits were found, so no actions are allowed. You need to get registered.",
|
||||
},
|
||||
|
||||
// Project assignment errors
|
||||
|
||||
@@ -59,7 +59,7 @@ export const PASSKEYS_ENABLED =
|
||||
export interface NotificationIface {
|
||||
group: string; // "alert" | "modal"
|
||||
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
||||
title: string;
|
||||
title?: string;
|
||||
text?: string;
|
||||
callback?: (success: boolean) => Promise<void>; // if this triggered an action
|
||||
noText?: string;
|
||||
@@ -68,4 +68,11 @@ export interface NotificationIface {
|
||||
onYes?: () => Promise<void>;
|
||||
promptToStopAsking?: boolean;
|
||||
yesText?: string;
|
||||
membersData?: Array<{
|
||||
member: { admitted: boolean; content: string; memberId: number };
|
||||
name: string;
|
||||
did: string;
|
||||
isContact: boolean;
|
||||
contact?: { did: string; name?: string; seesMe?: boolean };
|
||||
}>; // For passing member data to visibility dialog
|
||||
}
|
||||
|
||||
@@ -510,14 +510,6 @@ export const NOTIFY_REGISTER_CONTACT = {
|
||||
text: "Do you want to register them?",
|
||||
};
|
||||
|
||||
// Used in: ContactsView.vue (showOnboardMeetingDialog method - complex modal for onboarding meeting)
|
||||
export const NOTIFY_ONBOARDING_MEETING = {
|
||||
title: "Onboarding Meeting",
|
||||
text: "Would you like to start a new meeting?",
|
||||
yesText: "Start New Meeting",
|
||||
noText: "Join Existing Meeting",
|
||||
};
|
||||
|
||||
// TestView.vue specific constants
|
||||
// Used in: TestView.vue (executeSql method - SQL error handling)
|
||||
export const NOTIFY_SQL_ERROR = {
|
||||
@@ -1689,3 +1681,11 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = {
|
||||
title: "They're Added To Your List",
|
||||
message: "Would you like to go to the main page now?",
|
||||
};
|
||||
|
||||
// ImportAccountView.vue specific constants
|
||||
// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning)
|
||||
export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = {
|
||||
title: "Account Already Imported",
|
||||
message:
|
||||
"This account has already been imported. Please use a different seed phrase or check your existing accounts.",
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "../services/migrationService";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
import { arrayBufferToBase64 } from "@/libs/crypto";
|
||||
import { logger } from "@/utils/logger";
|
||||
|
||||
// Generate a random secret for the secret table
|
||||
|
||||
@@ -28,7 +29,61 @@ import { arrayBufferToBase64 } from "@/libs/crypto";
|
||||
// where they couldn't take action because they couldn't unlock that identity.)
|
||||
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const secretBase64 = arrayBufferToBase64(randomBytes);
|
||||
const secretBase64 = arrayBufferToBase64(randomBytes.buffer);
|
||||
|
||||
// Single source of truth for migration 004 SQL
|
||||
const MIG_004_SQL = `
|
||||
-- Migration 004: active_identity_management (CONSOLIDATED)
|
||||
-- Combines original migrations 004, 005, and 006 into single atomic operation
|
||||
-- CRITICAL SECURITY: Uses ON DELETE RESTRICT constraint from the start
|
||||
-- Assumes master code deployed with migration 003 (hasBackedUpSeed)
|
||||
|
||||
-- Enable foreign key constraints for data integrity
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Add UNIQUE constraint to accounts.did for foreign key support
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_did_unique ON accounts(did);
|
||||
|
||||
-- Create active_identity table with SECURE constraint (ON DELETE RESTRICT)
|
||||
-- This prevents accidental account deletion - critical security feature
|
||||
CREATE TABLE IF NOT EXISTS active_identity (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
activeDid TEXT REFERENCES accounts(did) ON DELETE RESTRICT,
|
||||
lastUpdated TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Add performance indexes
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_identity_single_record ON active_identity(id);
|
||||
|
||||
-- Seed singleton row (only if not already exists)
|
||||
INSERT INTO active_identity (id, activeDid, lastUpdated)
|
||||
SELECT 1, NULL, datetime('now')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM active_identity WHERE id = 1);
|
||||
|
||||
-- MIGRATE EXISTING DATA: Copy activeDid from settings to active_identity
|
||||
-- This prevents data loss when migration runs on existing databases
|
||||
UPDATE active_identity
|
||||
SET activeDid = (SELECT activeDid FROM settings WHERE id = 1),
|
||||
lastUpdated = datetime('now')
|
||||
WHERE id = 1
|
||||
AND EXISTS (SELECT 1 FROM settings WHERE id = 1 AND activeDid IS NOT NULL AND activeDid != '');
|
||||
|
||||
-- Copy important settings that were set in the MASTER_SETTINGS_KEY to the main identity.
|
||||
-- (We're not doing them all because some were already identity-specific and others aren't as critical.)
|
||||
UPDATE settings
|
||||
SET lastViewedClaimId = (SELECT lastViewedClaimId FROM settings WHERE id = 1),
|
||||
profileImageUrl = (SELECT profileImageUrl FROM settings WHERE id = 1),
|
||||
showShortcutBvc = (SELECT showShortcutBvc FROM settings WHERE id = 1),
|
||||
warnIfProdServer = (SELECT warnIfProdServer FROM settings WHERE id = 1),
|
||||
warnIfTestServer = (SELECT warnIfTestServer FROM settings WHERE id = 1)
|
||||
WHERE id = 2;
|
||||
|
||||
-- CLEANUP: Remove orphaned settings records and clear legacy activeDid values
|
||||
-- which usually simply deletes the MASTER_SETTINGS_KEY record.
|
||||
-- This completes the migration from settings-based to table-based active identity
|
||||
DELETE FROM settings WHERE accountDid IS NULL;
|
||||
UPDATE settings SET activeDid = NULL;
|
||||
`;
|
||||
|
||||
// Each migration can include multiple SQL statements (with semicolons)
|
||||
const MIGRATIONS = [
|
||||
@@ -124,8 +179,52 @@ const MIGRATIONS = [
|
||||
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "003_add_hasBackedUpSeed_to_settings",
|
||||
sql: `
|
||||
-- Add hasBackedUpSeed field to settings
|
||||
-- This migration assumes master code has been deployed
|
||||
-- The error handling will catch this if column already exists and mark migration as applied
|
||||
ALTER TABLE settings ADD COLUMN hasBackedUpSeed BOOLEAN DEFAULT FALSE;
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "004_active_identity_management",
|
||||
sql: MIG_004_SQL,
|
||||
},
|
||||
{
|
||||
name: "005_add_starredPlanHandleIds_to_settings",
|
||||
sql: `
|
||||
ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string
|
||||
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract single value from database query result
|
||||
* Works with different database service result formats
|
||||
*/
|
||||
function extractSingleValue<T>(result: T): string | number | null {
|
||||
if (!result) return null;
|
||||
|
||||
// Handle AbsurdSQL format: QueryExecResult[]
|
||||
if (Array.isArray(result) && result.length > 0 && result[0]?.values) {
|
||||
const values = result[0].values;
|
||||
return values.length > 0 ? values[0][0] : null;
|
||||
}
|
||||
|
||||
// Handle Capacitor SQLite format: { values: unknown[][] }
|
||||
if (typeof result === "object" && result !== null && "values" in result) {
|
||||
const values = (result as { values: unknown[][] }).values;
|
||||
return values && values.length > 0
|
||||
? (values[0][0] as string | number)
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sqlExec - A function that executes a SQL statement and returns the result
|
||||
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
|
||||
@@ -135,8 +234,57 @@ export async function runMigrations<T>(
|
||||
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
||||
extractMigrationNames: (result: T) => Set<string>,
|
||||
): Promise<void> {
|
||||
logger.debug("[Migration] Starting database migrations");
|
||||
|
||||
for (const migration of MIGRATIONS) {
|
||||
logger.debug("[Migration] Registering migration:", migration.name);
|
||||
registerMigration(migration);
|
||||
}
|
||||
|
||||
logger.debug("[Migration] Running migration service");
|
||||
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
|
||||
|
||||
logger.debug("[Migration] Database migrations completed");
|
||||
|
||||
// Bootstrapping: Ensure active account is selected after migrations
|
||||
logger.debug("[Migration] Running bootstrapping hooks");
|
||||
try {
|
||||
// Check if we have accounts but no active selection
|
||||
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
|
||||
const accountsCount = (extractSingleValue(accountsResult) as number) || 0;
|
||||
|
||||
// Check if active_identity table exists, and if not, try to recover
|
||||
let activeDid: string | null = null;
|
||||
try {
|
||||
const activeResult = await sqlQuery(
|
||||
"SELECT activeDid FROM active_identity WHERE id = 1",
|
||||
);
|
||||
activeDid = (extractSingleValue(activeResult) as string) || null;
|
||||
} catch (error) {
|
||||
// Table doesn't exist - migration 004 may not have run yet
|
||||
logger.debug(
|
||||
"[Migration] active_identity table not found - migration may not have run",
|
||||
);
|
||||
activeDid = null;
|
||||
}
|
||||
|
||||
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
|
||||
logger.debug("[Migration] Auto-selecting first account as active");
|
||||
const firstAccountResult = await sqlQuery(
|
||||
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
|
||||
);
|
||||
const firstAccountDid =
|
||||
(extractSingleValue(firstAccountResult) as string) || null;
|
||||
|
||||
if (firstAccountDid) {
|
||||
await sqlExec(
|
||||
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
|
||||
[firstAccountDid],
|
||||
);
|
||||
logger.info(`[Migration] Set active account to: ${firstAccountDid}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("[Migration] Bootstrapping hook failed (non-critical):", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,34 +9,6 @@ import { logger } from "@/utils/logger";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
import { QueryExecResult } from "@/interfaces/database";
|
||||
|
||||
export async function updateDefaultSettings(
|
||||
settingsChanges: Settings,
|
||||
): Promise<boolean> {
|
||||
delete settingsChanges.accountDid; // just in case
|
||||
// ensure there is no "id" that would override the key
|
||||
delete settingsChanges.id;
|
||||
try {
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const { sql, params } = generateUpdateStatement(
|
||||
settingsChanges,
|
||||
"settings",
|
||||
"id = ?",
|
||||
[MASTER_SETTINGS_KEY],
|
||||
);
|
||||
const result = await platformService.dbExec(sql, params);
|
||||
return result.changes === 1;
|
||||
} catch (error) {
|
||||
logger.error("Error updating default settings:", error);
|
||||
if (error instanceof Error) {
|
||||
throw error; // Re-throw if it's already an Error with a message
|
||||
} else {
|
||||
throw new Error(
|
||||
`Failed to update settings. We recommend you try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function insertDidSpecificSettings(
|
||||
did: string,
|
||||
settings: Partial<Settings> = {},
|
||||
@@ -91,6 +63,7 @@ export async function updateDidSpecificSettings(
|
||||
? mapColumnsToValues(postUpdateResult.columns, postUpdateResult.values)[0]
|
||||
: null;
|
||||
|
||||
// Note that we want to eliminate this check (and fix the above if it doesn't work).
|
||||
// Check if any of the target fields were actually changed
|
||||
let actuallyUpdated = false;
|
||||
if (currentRecord && updatedRecord) {
|
||||
@@ -157,10 +130,11 @@ export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
|
||||
result.columns,
|
||||
result.values,
|
||||
)[0] as Settings;
|
||||
if (settings.searchBoxes) {
|
||||
// @ts-expect-error - the searchBoxes field is a string in the DB
|
||||
settings.searchBoxes = JSON.parse(settings.searchBoxes);
|
||||
}
|
||||
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
|
||||
settings.starredPlanHandleIds = parseJsonField(
|
||||
settings.starredPlanHandleIds,
|
||||
[],
|
||||
);
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
@@ -226,10 +200,11 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle searchBoxes parsing
|
||||
if (settings.searchBoxes) {
|
||||
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
|
||||
}
|
||||
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
|
||||
settings.starredPlanHandleIds = parseJsonField(
|
||||
settings.starredPlanHandleIds,
|
||||
[],
|
||||
);
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
@@ -567,6 +542,8 @@ export async function debugSettingsData(did?: string): Promise<void> {
|
||||
* - Web SQLite (wa-sqlite/absurd-sql): Auto-parses JSON strings to objects
|
||||
* - Capacitor SQLite: Returns raw strings that need manual parsing
|
||||
*
|
||||
* Maybe consolidate with PlatformServiceMixin._parseJsonField
|
||||
*
|
||||
* @param value The value to parse (could be string or already parsed object)
|
||||
* @param defaultValue Default value if parsing fails
|
||||
* @returns Parsed object or default value
|
||||
|
||||
14
src/db/tables/activeIdentity.ts
Normal file
14
src/db/tables/activeIdentity.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* ActiveIdentity type describes the active identity selection.
|
||||
* This replaces the activeDid field in the settings table for better
|
||||
* database architecture and data integrity.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @since 2025-08-29
|
||||
*/
|
||||
|
||||
export interface ActiveIdentity {
|
||||
id: number;
|
||||
activeDid: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export type Contact = {
|
||||
// When adding a property:
|
||||
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
|
||||
// - If it's a boolean, it should be converted from a 0/1 integer in PlatformServiceMixin._mapColumnsToValues
|
||||
// - If it's a JSON string, it should be converted to an object/array in PlatformServiceMixin._mapColumnsToValues
|
||||
//
|
||||
|
||||
did: string;
|
||||
contactMethods?: Array<ContactMethod>;
|
||||
|
||||
@@ -14,6 +14,12 @@ export type BoundingBox = {
|
||||
* New entries that are boolean should also be added to PlatformServiceMixin._mapColumnsToValues
|
||||
*/
|
||||
export type Settings = {
|
||||
//
|
||||
// When adding a property:
|
||||
// - If it's a boolean, it should be converted from a 0/1 integer in PlatformServiceMixin._mapColumnsToValues
|
||||
// - If it's a JSON string, it should be converted to an object/array in PlatformServiceMixin._mapColumnsToValues
|
||||
//
|
||||
|
||||
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
|
||||
id?: string | number; // this is erased for all those entries that are keyed with accountDid
|
||||
|
||||
@@ -29,6 +35,7 @@ export type Settings = {
|
||||
finishedOnboarding?: boolean; // the user has completed the onboarding process
|
||||
|
||||
firstName?: string; // user's full name, may be null if unwanted for a particular account
|
||||
hasBackedUpSeed?: boolean; // tracks whether the user has backed up their seed phrase
|
||||
hideRegisterPromptOnNewContact?: boolean;
|
||||
isRegistered?: boolean;
|
||||
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
|
||||
@@ -36,6 +43,7 @@ export type Settings = {
|
||||
|
||||
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
|
||||
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
||||
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan changes that they've acknowledged seeing
|
||||
|
||||
// The claim list has a most recent one used in notifications that's separate from the last viewed
|
||||
lastNotifiedClaimId?: string;
|
||||
@@ -60,15 +68,18 @@ export type Settings = {
|
||||
showContactGivesInline?: boolean; // Display contact inline or not
|
||||
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
|
||||
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
|
||||
|
||||
starredPlanHandleIds?: string[]; // Array of starred plan handle IDs
|
||||
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
||||
warnIfProdServer?: boolean; // Warn if using a production server
|
||||
warnIfTestServer?: boolean; // Warn if using a testing server
|
||||
webPushServer?: string; // Web Push server URL
|
||||
};
|
||||
|
||||
// type of settings where the searchBoxes are JSON strings instead of objects
|
||||
// type of settings where the values are JSON strings instead of objects
|
||||
export type SettingsWithJsonStrings = Settings & {
|
||||
searchBoxes: string;
|
||||
starredPlanHandleIds: string;
|
||||
};
|
||||
|
||||
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
|
||||
@@ -85,6 +96,11 @@ export const SettingsSchema = {
|
||||
/**
|
||||
* Constants.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is deprecated.
|
||||
* It only remains for those with a PWA who have not migrated, but we'll soon remove it.
|
||||
*/
|
||||
export const MASTER_SETTINGS_KEY = "1";
|
||||
|
||||
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;
|
||||
|
||||
@@ -14,6 +14,13 @@ export interface AgreeActionClaim extends ClaimObject {
|
||||
object: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EmojiClaim extends ClaimObject {
|
||||
// default context is "https://endorser.ch"
|
||||
"@type": "Emoji";
|
||||
text: string;
|
||||
parentItem: { lastClaimId: string };
|
||||
}
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id4
|
||||
export interface GiveActionClaim extends ClaimObject {
|
||||
@@ -72,11 +79,15 @@ export interface PlanActionClaim extends ClaimObject {
|
||||
name: string;
|
||||
agent?: { identifier: string };
|
||||
description?: string;
|
||||
endTime?: string;
|
||||
identifier?: string;
|
||||
image?: string;
|
||||
lastClaimId?: string;
|
||||
location?: {
|
||||
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
|
||||
};
|
||||
startTime?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// AKA Registration & RegisterAction
|
||||
|
||||
@@ -70,18 +70,11 @@ export interface AxiosErrorResponse {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
did: string;
|
||||
name: string;
|
||||
publicEncKey: string;
|
||||
registered: boolean;
|
||||
profileImageUrl?: string;
|
||||
nextPublicEncKeyHash?: string;
|
||||
}
|
||||
|
||||
export interface CreateAndSubmitClaimResult {
|
||||
success: boolean;
|
||||
embeddedRecordError?: string;
|
||||
error?: string;
|
||||
claimId?: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
export type {
|
||||
// From common.ts
|
||||
CreateAndSubmitClaimResult,
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
KeyMeta,
|
||||
// Exclude types that are also exported from other files
|
||||
// GiveVerifiableCredential,
|
||||
// OfferVerifiableCredential,
|
||||
// RegisterVerifiableCredential,
|
||||
// PlanSummaryRecord,
|
||||
// UserInfo,
|
||||
} from "./common";
|
||||
|
||||
export type {
|
||||
// From claims.ts
|
||||
GiveActionClaim,
|
||||
OfferClaim,
|
||||
RegisterActionClaim,
|
||||
} from "./claims";
|
||||
|
||||
export type {
|
||||
// From records.ts
|
||||
PlanSummaryRecord,
|
||||
} from "./records";
|
||||
|
||||
export type {
|
||||
// From user.ts
|
||||
UserInfo,
|
||||
} from "./user";
|
||||
|
||||
export * from "./limits";
|
||||
export * from "./deepLinks";
|
||||
export * from "./common";
|
||||
export * from "./claims";
|
||||
export * from "./claims-result";
|
||||
export * from "./common";
|
||||
export * from "./deepLinks";
|
||||
export * from "./limits";
|
||||
export * from "./records";
|
||||
export * from "./user";
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { GiveActionClaim, OfferClaim } from "./claims";
|
||||
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
|
||||
import { GenericCredWrapper } from "./common";
|
||||
|
||||
export interface EmojiSummaryRecord {
|
||||
issuerDid: string;
|
||||
jwtId: string;
|
||||
text: string;
|
||||
parentHandleId: string;
|
||||
}
|
||||
|
||||
// a summary record; the VC is found the fullClaim field
|
||||
export interface GiveSummaryRecord {
|
||||
[x: string]: PropertyKey | undefined | GiveActionClaim;
|
||||
[x: string]:
|
||||
| PropertyKey
|
||||
| undefined
|
||||
| GiveActionClaim
|
||||
| Record<string, number>;
|
||||
type?: string;
|
||||
agentDid: string;
|
||||
amount: number;
|
||||
amountConfirmed: number;
|
||||
description: string;
|
||||
emojiCount: Record<string, number>; // Map of emoji character to count
|
||||
fullClaim: GiveActionClaim;
|
||||
fulfillsHandleId: string;
|
||||
fulfillsPlanHandleId?: string;
|
||||
@@ -61,6 +74,11 @@ export interface PlanSummaryRecord {
|
||||
jwtId?: string;
|
||||
}
|
||||
|
||||
export interface PlanSummaryAndPreviousClaim {
|
||||
plan: PlanSummaryRecord;
|
||||
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents data about a project
|
||||
*
|
||||
@@ -87,7 +105,10 @@ export interface PlanData {
|
||||
name: string;
|
||||
/**
|
||||
* The identifier of the project record -- different from jwtId
|
||||
* (Maybe we should use the jwtId to iterate through the records instead.)
|
||||
*
|
||||
* This has been used to iterate through plan records, because jwtId ordering doesn't match
|
||||
* chronological create ordering, though it does match most recent edit order (in reverse order).
|
||||
* (It may be worthwhile to order by jwtId instead. It is an indexed field.)
|
||||
**/
|
||||
rowId?: string;
|
||||
}
|
||||
|
||||
@@ -6,3 +6,12 @@ export interface UserInfo {
|
||||
profileImageUrl?: string;
|
||||
nextPublicEncKeyHash?: string;
|
||||
}
|
||||
|
||||
export interface MemberData {
|
||||
did: string;
|
||||
name: string;
|
||||
isContact: boolean;
|
||||
member: {
|
||||
memberId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +104,71 @@ export const accessToken = async (did?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a longer-lived access token for background operations
|
||||
*
|
||||
* This function creates JWT tokens with extended validity (default 72 hours) for use
|
||||
* in background prefetch operations. The longer expiration period allows the daily
|
||||
* notification plugin to work offline for extended periods without requiring the app
|
||||
* to be in the foreground to refresh tokens.
|
||||
*
|
||||
* **Token Refresh Strategy (Hybrid Approach):**
|
||||
* - Tokens are valid for 72 hours (configurable)
|
||||
* - Tokens are refreshed proactively when:
|
||||
* - App comes to foreground (via Capacitor 'resume' event)
|
||||
* - Component mounts (DailyNotificationSection)
|
||||
* - Notifications are enabled
|
||||
* - If token expires while offline, plugin uses cached content
|
||||
* - Next time app opens, token is automatically refreshed
|
||||
*
|
||||
* **Why 72 Hours?**
|
||||
* - Balances security (read-only prefetch operations) with offline capability
|
||||
* - Reduces need for app to wake itself for token refresh
|
||||
* - Allows plugin to work offline for extended periods (e.g., weekend trips)
|
||||
* - Longer than typical prefetch windows (5 minutes before notification)
|
||||
*
|
||||
* **Security Considerations:**
|
||||
* - Tokens are used only for read-only prefetch operations
|
||||
* - Tokens are stored securely in plugin's Room database
|
||||
* - Tokens are refreshed proactively to minimize exposure window
|
||||
* - No private keys are exposed to native code
|
||||
*
|
||||
* @param {string} did - User DID (Decentralized Identifier) for token issuer
|
||||
* @param {number} expirationMinutes - Optional expiration in minutes (defaults to 72 hours = 4320 minutes)
|
||||
* @return {Promise<string>} JWT token with extended validity, or empty string if no DID provided
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Generate token with default 72-hour expiration
|
||||
* const token = await accessTokenForBackground("did:ethr:0x...");
|
||||
*
|
||||
* // Generate token with custom expiration (24 hours)
|
||||
* const token24h = await accessTokenForBackground("did:ethr:0x...", 24 * 60);
|
||||
* ```
|
||||
*
|
||||
* @see {@link accessToken} For short-lived tokens (1 minute) for regular API requests
|
||||
* @see {@link createEndorserJwtForDid} For JWT creation implementation
|
||||
*/
|
||||
export const accessTokenForBackground = async (
|
||||
did?: string,
|
||||
expirationMinutes?: number,
|
||||
): Promise<string> => {
|
||||
if (!did) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Use provided expiration or default to 72 hours (4320 minutes)
|
||||
// This allows background prefetch operations to work offline for extended periods
|
||||
const expirationSeconds = expirationMinutes
|
||||
? expirationMinutes * 60
|
||||
: 72 * 60 * 60; // Default 72 hours
|
||||
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const endEpoch = nowEpoch + expirationSeconds;
|
||||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||
return createEndorserJwtForDid(did, tokenPayload);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract JWT from various URL formats
|
||||
* @param jwtUrlText The URL containing the JWT
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* @module endorserServer
|
||||
*/
|
||||
|
||||
import { Axios, AxiosRequestConfig } from "axios";
|
||||
import { Axios, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { Buffer } from "buffer";
|
||||
import { sha256 } from "ethereum-cryptography/sha256";
|
||||
import { LRUCache } from "lru-cache";
|
||||
@@ -42,9 +42,6 @@ import {
|
||||
PlanActionClaim,
|
||||
RegisterActionClaim,
|
||||
TenureClaim,
|
||||
} from "../interfaces/claims";
|
||||
|
||||
import {
|
||||
GenericCredWrapper,
|
||||
GenericVerifiableCredential,
|
||||
AxiosErrorResponse,
|
||||
@@ -55,9 +52,12 @@ import {
|
||||
QuantitativeValue,
|
||||
KeyMetaWithPrivate,
|
||||
KeyMetaMaybeWithPrivate,
|
||||
} from "../interfaces/common";
|
||||
import { PlanSummaryRecord } from "../interfaces/records";
|
||||
import { logger } from "../utils/logger";
|
||||
OfferSummaryRecord,
|
||||
OfferToPlanSummaryRecord,
|
||||
PlanSummaryAndPreviousClaim,
|
||||
PlanSummaryRecord,
|
||||
} from "../interfaces";
|
||||
import { logger, safeStringify } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { APP_SERVER } from "@/constants/app";
|
||||
import { SOMEONE_UNNAMED } from "@/constants/entities";
|
||||
@@ -315,7 +315,7 @@ export function didInfoForContact(
|
||||
return { displayName: "You", known: true };
|
||||
} else if (contact) {
|
||||
return {
|
||||
displayName: contact.name || "Contact With No Name",
|
||||
displayName: contact.name || "Contact Without a Name",
|
||||
known: true,
|
||||
profileImageUrl: contact.profileImageUrl,
|
||||
};
|
||||
@@ -362,6 +362,22 @@ export function didInfo(
|
||||
return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* In some contexts (eg. agent), a blank really is nobody.
|
||||
*/
|
||||
export function didInfoOrNobody(
|
||||
did: string | undefined,
|
||||
activeDid: string | undefined,
|
||||
allMyDids: string[],
|
||||
contacts: Contact[],
|
||||
): string {
|
||||
if (did == null) {
|
||||
return "Nobody";
|
||||
} else {
|
||||
return didInfo(did, activeDid, allMyDids, contacts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* return text description without any references to "you" as user
|
||||
*/
|
||||
@@ -486,6 +502,15 @@ const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
|
||||
max: 500,
|
||||
});
|
||||
|
||||
/**
|
||||
* Tracks in-flight requests to prevent duplicate API calls for the same plan
|
||||
* @constant {Map}
|
||||
*/
|
||||
const inFlightRequests = new Map<
|
||||
string,
|
||||
Promise<PlanSummaryRecord | undefined>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Retrieves plan data from cache or server
|
||||
* @param {string} handleId - Plan handle ID
|
||||
@@ -505,40 +530,136 @@ export async function getPlanFromCache(
|
||||
if (!handleId) {
|
||||
return undefined;
|
||||
}
|
||||
let cred = planCache.get(handleId);
|
||||
if (!cred) {
|
||||
const url =
|
||||
apiServer +
|
||||
"/api/v2/report/plans?handleId=" +
|
||||
encodeURIComponent(handleId);
|
||||
const headers = await getHeaders(requesterDid);
|
||||
try {
|
||||
const resp = await axios.get(url, { headers });
|
||||
if (resp.status === 200 && resp.data?.data?.length > 0) {
|
||||
cred = resp.data.data[0];
|
||||
planCache.set(handleId, cred);
|
||||
} else {
|
||||
// Use debug level for development to reduce console noise
|
||||
const isDevelopment = process.env.VITE_PLATFORM === "development";
|
||||
const log = isDevelopment ? logger.debug : logger.log;
|
||||
|
||||
log(
|
||||
"[EndorserServer] Plan cache is empty for handle",
|
||||
handleId,
|
||||
" Got data:",
|
||||
JSON.stringify(resp.data),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[EndorserServer] Failed to load plan with handle",
|
||||
handleId,
|
||||
" Got error:",
|
||||
JSON.stringify(error),
|
||||
);
|
||||
}
|
||||
// Check cache first (existing behavior)
|
||||
const cred = planCache.get(handleId);
|
||||
if (cred) {
|
||||
return cred;
|
||||
}
|
||||
|
||||
// Check if request is already in flight (NEW: request deduplication)
|
||||
if (inFlightRequests.has(handleId)) {
|
||||
logger.debug(
|
||||
"[Plan Loading] 🔄 Request already in flight, reusing promise:",
|
||||
{
|
||||
handleId,
|
||||
requesterDid,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
return inFlightRequests.get(handleId);
|
||||
}
|
||||
|
||||
// Create new request promise (NEW: request coordination)
|
||||
const requestPromise = performPlanRequest(
|
||||
handleId,
|
||||
axios,
|
||||
apiServer,
|
||||
requesterDid,
|
||||
);
|
||||
inFlightRequests.set(handleId, requestPromise);
|
||||
|
||||
try {
|
||||
const result = await requestPromise;
|
||||
return result;
|
||||
} finally {
|
||||
// Clean up in-flight request tracking (NEW: cleanup)
|
||||
inFlightRequests.delete(handleId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual plan request to the server
|
||||
* @param {string} handleId - Plan handle ID
|
||||
* @param {Axios} axios - Axios instance
|
||||
* @param {string} apiServer - API server URL
|
||||
* @param {string} [requesterDid] - Optional requester DID for private info
|
||||
* @returns {Promise<PlanSummaryRecord|undefined>} Plan data or undefined if not found
|
||||
*
|
||||
* @throws {Error} If server request fails
|
||||
*/
|
||||
async function performPlanRequest(
|
||||
handleId: string,
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
requesterDid?: string,
|
||||
): Promise<PlanSummaryRecord | undefined> {
|
||||
const url =
|
||||
apiServer + "/api/v2/report/plans?handleId=" + encodeURIComponent(handleId);
|
||||
const headers = await getHeaders(requesterDid);
|
||||
|
||||
// Enhanced diagnostic logging for plan loading
|
||||
const requestId = `plan_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.debug("[Plan Loading] 🔍 Loading plan from server:", {
|
||||
requestId,
|
||||
handleId,
|
||||
apiServer,
|
||||
endpoint: url,
|
||||
requesterDid,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await axios.get(url, { headers });
|
||||
|
||||
logger.debug("[Plan Loading] ✅ Plan loaded successfully:", {
|
||||
requestId,
|
||||
handleId,
|
||||
status: resp.status,
|
||||
hasData: !!resp.data?.data,
|
||||
dataLength: resp.data?.data?.length || 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (resp.status === 200 && resp.data?.data?.length > 0) {
|
||||
const cred = resp.data.data[0];
|
||||
planCache.set(handleId, cred);
|
||||
|
||||
logger.debug("[Plan Loading] 💾 Plan cached:", {
|
||||
requestId,
|
||||
handleId,
|
||||
planName: cred?.name,
|
||||
planIssuer: cred?.issuerDid,
|
||||
});
|
||||
|
||||
return cred;
|
||||
} else {
|
||||
logger.debug(
|
||||
"[Plan Loading] ⚠️ Plan cache is empty for handle",
|
||||
handleId,
|
||||
" Got data:",
|
||||
JSON.stringify(resp.data),
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
// Enhanced error logging for plan loading failures
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: unknown;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
logger.error("[Plan Loading] ❌ Failed to load plan:", {
|
||||
requestId,
|
||||
handleId,
|
||||
apiServer,
|
||||
endpoint: url,
|
||||
requesterDid,
|
||||
errorStatus: axiosError.response?.status,
|
||||
errorStatusText: axiosError.response?.statusText,
|
||||
errorData: axiosError.response?.data,
|
||||
errorMessage: axiosError.message || String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
return cred;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -576,7 +697,7 @@ export function serverMessageForUser(error: unknown): string | undefined {
|
||||
export function errorStringForLog(error: unknown) {
|
||||
let stringifiedError = "" + error;
|
||||
try {
|
||||
stringifiedError = JSON.stringify(error);
|
||||
stringifiedError = safeStringify(error);
|
||||
} catch (e) {
|
||||
// can happen with Dexie, eg:
|
||||
// TypeError: Converting circular structure to JSON
|
||||
@@ -588,7 +709,7 @@ export function errorStringForLog(error: unknown) {
|
||||
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const err = error as AxiosErrorResponse;
|
||||
const errorResponseText = JSON.stringify(err.response);
|
||||
const errorResponseText = safeStringify(err.response);
|
||||
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
|
||||
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
|
||||
// add error.response stuff
|
||||
@@ -598,7 +719,7 @@ export function errorStringForLog(error: unknown) {
|
||||
R.equals(err.config, err.response.config)
|
||||
) {
|
||||
// but exclude "config" because it's already in there
|
||||
const newErrorResponseText = JSON.stringify(
|
||||
const newErrorResponseText = safeStringify(
|
||||
R.omit(["config"] as never[], err.response),
|
||||
);
|
||||
fullError +=
|
||||
@@ -621,7 +742,7 @@ export async function getNewOffersToUser(
|
||||
activeDid: string,
|
||||
afterOfferJwtId?: string,
|
||||
beforeOfferJwtId?: string,
|
||||
) {
|
||||
): Promise<{ data: Array<OfferSummaryRecord>; hitLimit: boolean }> {
|
||||
let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`;
|
||||
if (afterOfferJwtId) {
|
||||
url += "&afterId=" + afterOfferJwtId;
|
||||
@@ -643,7 +764,7 @@ export async function getNewOffersToUserProjects(
|
||||
activeDid: string,
|
||||
afterOfferJwtId?: string,
|
||||
beforeOfferJwtId?: string,
|
||||
) {
|
||||
): Promise<{ data: Array<OfferToPlanSummaryRecord>; hitLimit: boolean }> {
|
||||
let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`;
|
||||
if (afterOfferJwtId) {
|
||||
url += "?afterId=" + afterOfferJwtId;
|
||||
@@ -657,6 +778,46 @@ export async function getNewOffersToUserProjects(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get starred projects that have been updated since the last check
|
||||
*
|
||||
* @param axios - axios instance
|
||||
* @param apiServer - endorser API server URL
|
||||
* @param activeDid - user's DID for authentication
|
||||
* @param starredPlanHandleIds - array of starred project handle IDs
|
||||
* @param afterId - JWT ID to check for changes after (from lastAckedStarredPlanChangesJwtId)
|
||||
* @returns { data: Array<PlanSummaryAndPreviousClaim>, hitLimit: boolean }
|
||||
*/
|
||||
export async function getStarredProjectsWithChanges(
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
activeDid: string,
|
||||
starredPlanHandleIds: string[],
|
||||
afterId?: string,
|
||||
): Promise<{ data: Array<PlanSummaryAndPreviousClaim>; hitLimit: boolean }> {
|
||||
if (!starredPlanHandleIds || starredPlanHandleIds.length === 0) {
|
||||
return { data: [], hitLimit: false };
|
||||
}
|
||||
|
||||
if (!afterId) {
|
||||
// This doesn't make sense: there should always be some previous one they've seen.
|
||||
// We'll just return blank.
|
||||
return { data: [], hitLimit: false };
|
||||
}
|
||||
|
||||
// Use POST method for larger lists of project IDs
|
||||
const url = `${apiServer}/api/v2/report/plansLastUpdatedBetween`;
|
||||
const headers = await getHeaders(activeDid);
|
||||
|
||||
const requestBody = {
|
||||
planIds: starredPlanHandleIds,
|
||||
afterId: afterId,
|
||||
};
|
||||
|
||||
const response = await axios.post(url, requestBody, { headers });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct GiveAction VC for submission to server
|
||||
*
|
||||
@@ -1019,19 +1180,87 @@ export async function createAndSubmitClaim(
|
||||
|
||||
const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload);
|
||||
|
||||
// Enhanced diagnostic logging for claim submission
|
||||
const requestId = `claim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
logger.debug("[Claim Submission] 🚀 Starting claim submission:", {
|
||||
requestId,
|
||||
apiServer,
|
||||
requesterDid: issuerDid,
|
||||
endpoint: `${apiServer}/api/v2/claim`,
|
||||
timestamp: new Date().toISOString(),
|
||||
jwtLength: vcJwt.length,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = `${apiServer}/api/v2/claim`;
|
||||
|
||||
logger.debug("[Claim Submission] 📡 Making API request:", {
|
||||
requestId,
|
||||
url,
|
||||
payloadSize: payload.length,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, handleId: response.data?.handleId };
|
||||
logger.debug("[Claim Submission] ✅ Claim submitted successfully:", {
|
||||
requestId,
|
||||
status: response.status,
|
||||
handleId: response.data?.handleId,
|
||||
responseSize: JSON.stringify(response.data).length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
claimId: response.data?.claimId,
|
||||
handleId: response.data?.handleId,
|
||||
embeddedRecordError: response.data?.embeddedRecordError,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error submitting claim:", error);
|
||||
// Enhanced error logging with comprehensive context
|
||||
const requestId = `claim_error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { error?: { code?: string; message?: string } };
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
config?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
logger.error("[Claim Submission] ❌ Claim submission failed:", {
|
||||
requestId,
|
||||
apiServer,
|
||||
requesterDid: issuerDid,
|
||||
endpoint: `${apiServer}/api/v2/claim`,
|
||||
errorCode: axiosError.response?.data?.error?.code,
|
||||
errorMessage: axiosError.response?.data?.error?.message,
|
||||
httpStatus: axiosError.response?.status,
|
||||
httpStatusText: axiosError.response?.statusText,
|
||||
responseHeaders: axiosError.response?.headers,
|
||||
requestConfig: {
|
||||
url: axiosError.config?.url,
|
||||
method: axiosError.config?.method,
|
||||
headers: axiosError.config?.headers,
|
||||
},
|
||||
originalError: axiosError.message || String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const errorMessage: string =
|
||||
serverMessageForUser(error) ||
|
||||
(error && typeof error === "object" && "message" in error
|
||||
@@ -1141,6 +1370,28 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
|
||||
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats type string for display by adding spaces before capitals
|
||||
* and optionally adds an appropriate article prefix (a/an)
|
||||
*
|
||||
* @param text - Text to format
|
||||
* @returns Formatted string with article prefix
|
||||
*/
|
||||
export const capitalizeAndInsertSpacesBeforeCapsWithAPrefix = (
|
||||
text: string,
|
||||
): string => {
|
||||
const word = capitalizeAndInsertSpacesBeforeCaps(text);
|
||||
if (word) {
|
||||
// if the word starts with a vowel, use "an" instead of "a"
|
||||
const firstLetter = word[0].toLowerCase();
|
||||
const vowels = ["a", "e", "i", "o", "u"];
|
||||
const particle = vowels.includes(firstLetter) ? "an" : "a";
|
||||
return particle + " " + word;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
return readable summary of claim, or something generic
|
||||
|
||||
@@ -1406,31 +1657,39 @@ export async function register(
|
||||
message?: string;
|
||||
}>(url, { jwtEncoded: vcJwt });
|
||||
|
||||
if (resp.data?.success?.handleId) {
|
||||
return { success: true };
|
||||
} else if (resp.data?.success?.embeddedRecordError) {
|
||||
if (resp.data?.success?.embeddedRecordError) {
|
||||
let message =
|
||||
"There was some problem with the registration and so it may not be complete.";
|
||||
if (typeof resp.data.success.embeddedRecordError === "string") {
|
||||
message += " " + resp.data.success.embeddedRecordError;
|
||||
}
|
||||
return { error: message };
|
||||
} else if (resp.data?.success?.handleId) {
|
||||
return { success: true };
|
||||
} else {
|
||||
logger.error("Registration error:", JSON.stringify(resp.data));
|
||||
return { error: "Got a server error when registering." };
|
||||
logger.error("Registration non-thrown error:", JSON.stringify(resp.data));
|
||||
return {
|
||||
error:
|
||||
(resp.data?.error as { message?: string })?.message ||
|
||||
(resp.data?.error as string) ||
|
||||
"Got a server error when registering.",
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object") {
|
||||
const err = error as AxiosErrorResponse;
|
||||
const errorMessage =
|
||||
err.message ||
|
||||
(err.response?.data &&
|
||||
typeof err.response.data === "object" &&
|
||||
"message" in err.response.data
|
||||
? (err.response.data as { message: string }).message
|
||||
: undefined);
|
||||
logger.error("Registration error:", errorMessage || JSON.stringify(err));
|
||||
return { error: errorMessage || "Got a server error when registering." };
|
||||
err.response?.data?.error?.message ||
|
||||
err.response?.data?.error ||
|
||||
err.message;
|
||||
logger.error(
|
||||
"Registration thrown error:",
|
||||
errorMessage || JSON.stringify(err),
|
||||
);
|
||||
return {
|
||||
error:
|
||||
(errorMessage as string) || "Got a server error when registering.",
|
||||
};
|
||||
}
|
||||
return { error: "Got a server error when registering." };
|
||||
}
|
||||
@@ -1494,16 +1753,28 @@ export async function fetchEndorserRateLimits(
|
||||
) {
|
||||
const url = `${apiServer}/api/report/rateLimits`;
|
||||
const headers = await getHeaders(issuerDid);
|
||||
try {
|
||||
const response = await axios.get(url, { headers } as AxiosRequestConfig);
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[fetchEndorserRateLimits] Error for DID ${issuerDid}:`,
|
||||
errorStringForLog(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Enhanced diagnostic logging for user registration tracking
|
||||
logger.debug("[User Registration] Checking user status on server:", {
|
||||
did: issuerDid,
|
||||
server: apiServer,
|
||||
endpoint: url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// not wrapped in a 'try' because the error returned is self-explanatory
|
||||
const response = await axios.get(url, { headers } as AxiosRequestConfig);
|
||||
|
||||
// Log successful registration check
|
||||
logger.debug("[User Registration] User registration check successful:", {
|
||||
did: issuerDid,
|
||||
server: apiServer,
|
||||
status: response.status,
|
||||
isRegistered: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1514,8 +1785,55 @@ export async function fetchEndorserRateLimits(
|
||||
* @param {string} issuerDid - The DID for which to check rate limits.
|
||||
* @returns {Promise<AxiosResponse>} The Axios response object.
|
||||
*/
|
||||
export async function fetchImageRateLimits(axios: Axios, issuerDid: string) {
|
||||
const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
|
||||
export async function fetchImageRateLimits(
|
||||
axios: Axios,
|
||||
issuerDid: string,
|
||||
imageServer?: string,
|
||||
): Promise<AxiosResponse | null> {
|
||||
const server = imageServer || DEFAULT_IMAGE_API_SERVER;
|
||||
const url = server + "/image-limits";
|
||||
const headers = await getHeaders(issuerDid);
|
||||
return await axios.get(url, { headers } as AxiosRequestConfig);
|
||||
|
||||
// Enhanced diagnostic logging for image server calls
|
||||
logger.debug("[Image Server] Checking image rate limits:", {
|
||||
did: issuerDid,
|
||||
server: server,
|
||||
endpoint: url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, { headers } as AxiosRequestConfig);
|
||||
|
||||
// Log successful image server call
|
||||
logger.debug("[Image Server] Image rate limits check successful:", {
|
||||
did: issuerDid,
|
||||
server: server,
|
||||
status: response.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Enhanced error logging for image server failures
|
||||
const axiosError = error as {
|
||||
response?: {
|
||||
data?: { error?: { code?: string; message?: string } };
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
|
||||
logger.warn(
|
||||
"[Image Server] Image rate limits check failed, which is expected for users not registered on test server (eg. when only registered on local server).",
|
||||
{
|
||||
did: issuerDid,
|
||||
server: server,
|
||||
errorCode: axiosError.response?.data?.error?.code,
|
||||
errorMessage: axiosError.response?.data?.error?.message,
|
||||
httpStatus: axiosError.response?.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleMinus,
|
||||
faCirclePlus,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faCrown,
|
||||
faDollar,
|
||||
faDownload,
|
||||
faEllipsis,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHourglassHalf,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
@@ -86,6 +89,7 @@ import {
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faStar,
|
||||
faThumbtack,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
@@ -94,6 +98,9 @@ import {
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
|
||||
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
|
||||
|
||||
// Initialize Font Awesome library with all required icons
|
||||
library.add(
|
||||
faArrowDown,
|
||||
@@ -119,6 +126,7 @@ library.add(
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleMinus,
|
||||
faCirclePlus,
|
||||
faCircleQuestion,
|
||||
faCircleRight,
|
||||
@@ -127,6 +135,7 @@ library.add(
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faCrown,
|
||||
faDollar,
|
||||
faDownload,
|
||||
faEllipsis,
|
||||
@@ -148,6 +157,7 @@ library.add(
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHourglassHalf,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
@@ -168,14 +178,16 @@ library.add(
|
||||
faPlus,
|
||||
faQrcode,
|
||||
faQuestion,
|
||||
faRotate,
|
||||
faRightFromBracket,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faStar,
|
||||
faStarRegular,
|
||||
faThumbtack,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
|
||||
293
src/libs/util.ts
293
src/libs/util.ts
@@ -3,7 +3,7 @@
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { Buffer } from "buffer";
|
||||
import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { copyToClipboard } from "../services/ClipboardService";
|
||||
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
||||
import { Account, AccountEncrypted } from "../db/tables/accounts";
|
||||
@@ -160,6 +160,49 @@ export const isGiveAction = (
|
||||
return isGiveClaimType(veriClaim.claimType);
|
||||
};
|
||||
|
||||
export interface OfferFulfillment {
|
||||
offerHandleId: string;
|
||||
offerType: string;
|
||||
}
|
||||
|
||||
interface FulfillmentItem {
|
||||
"@type": string;
|
||||
identifier?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract offer fulfillment information from the fulfills field
|
||||
* Handles both array and single object cases
|
||||
*/
|
||||
export const extractOfferFulfillment = (
|
||||
fulfills: FulfillmentItem | FulfillmentItem[] | null | undefined,
|
||||
): OfferFulfillment | null => {
|
||||
if (!fulfills) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle both array and single object cases
|
||||
let offerFulfill = null;
|
||||
|
||||
if (Array.isArray(fulfills)) {
|
||||
// Find the Offer in the fulfills array
|
||||
offerFulfill = fulfills.find((item) => item["@type"] === "Offer");
|
||||
} else if (fulfills["@type"] === "Offer") {
|
||||
// fulfills is a single Offer object
|
||||
offerFulfill = fulfills;
|
||||
}
|
||||
|
||||
if (offerFulfill) {
|
||||
return {
|
||||
offerHandleId: offerFulfill.identifier || "",
|
||||
offerType: offerFulfill["@type"],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const shortDid = (did: string) => {
|
||||
if (did.startsWith("did:peer:")) {
|
||||
return (
|
||||
@@ -197,11 +240,19 @@ export const nameForContact = (
|
||||
);
|
||||
};
|
||||
|
||||
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
||||
export const doCopyTwoSecRedo = async (
|
||||
text: string,
|
||||
fn: () => void,
|
||||
): Promise<void> => {
|
||||
fn();
|
||||
useClipboard()
|
||||
.copy(text)
|
||||
.then(() => setTimeout(fn, 2000));
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
setTimeout(fn, 2000);
|
||||
} catch (error) {
|
||||
// Note: This utility function doesn't have access to notification system
|
||||
// The calling component should handle error notifications
|
||||
// Error is silently caught to avoid breaking the 2-second redo pattern
|
||||
}
|
||||
};
|
||||
|
||||
export interface ConfirmerData {
|
||||
@@ -614,57 +665,65 @@ export const retrieveAllAccountsMetadata = async (): Promise<
|
||||
return result;
|
||||
};
|
||||
|
||||
export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account.";
|
||||
|
||||
/**
|
||||
* Saves a new identity to both SQL and Dexie databases
|
||||
* Saves a new identity to SQL database
|
||||
*/
|
||||
export async function saveNewIdentity(
|
||||
identity: IIdentifier,
|
||||
mnemonic: string,
|
||||
derivationPath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// add to the new sql db
|
||||
const platformService = await getPlatformService();
|
||||
// add to the new sql db
|
||||
const platformService = await getPlatformService();
|
||||
|
||||
const secrets = await platformService.dbQuery(
|
||||
`SELECT secretBase64 FROM secret`,
|
||||
);
|
||||
if (!secrets?.values?.length || !secrets.values[0]?.length) {
|
||||
throw new Error(
|
||||
"No initial encryption supported. We recommend you clear your data and start over.",
|
||||
);
|
||||
}
|
||||
// Check if account already exists before attempting to save
|
||||
const existingAccount = await platformService.dbQuery(
|
||||
"SELECT did FROM accounts WHERE did = ?",
|
||||
[identity.did],
|
||||
);
|
||||
|
||||
const secretBase64 = secrets.values[0][0] as string;
|
||||
|
||||
const secret = base64ToArrayBuffer(secretBase64);
|
||||
const identityStr = JSON.stringify(identity);
|
||||
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
|
||||
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
|
||||
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
|
||||
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
|
||||
|
||||
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const params = [
|
||||
new Date().toISOString(),
|
||||
derivationPath,
|
||||
identity.did,
|
||||
encryptedIdentityBase64,
|
||||
encryptedMnemonicBase64,
|
||||
identity.keys[0].publicKeyHex,
|
||||
];
|
||||
await platformService.dbExec(sql, params);
|
||||
|
||||
await platformService.updateDefaultSettings({ activeDid: identity.did });
|
||||
|
||||
await platformService.insertNewDidIntoSettings(identity.did);
|
||||
} catch (error) {
|
||||
logger.error("Failed to update default settings:", error);
|
||||
if (existingAccount?.values?.length) {
|
||||
throw new Error(
|
||||
"Failed to set default settings. Please try again or restart the app.",
|
||||
`Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`,
|
||||
);
|
||||
}
|
||||
|
||||
const secrets = await platformService.dbQuery(
|
||||
`SELECT secretBase64 FROM secret`,
|
||||
);
|
||||
if (!secrets?.values?.length || !secrets.values[0]?.length) {
|
||||
throw new Error(
|
||||
"No initial encryption supported. We recommend you clear your data and start over.",
|
||||
);
|
||||
}
|
||||
|
||||
const secretBase64 = secrets.values[0][0] as string;
|
||||
|
||||
const secret = base64ToArrayBuffer(secretBase64);
|
||||
const identityStr = JSON.stringify(identity);
|
||||
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
|
||||
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
|
||||
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
|
||||
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
|
||||
|
||||
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
const params = [
|
||||
new Date().toISOString(),
|
||||
derivationPath,
|
||||
identity.did,
|
||||
encryptedIdentityBase64,
|
||||
encryptedMnemonicBase64,
|
||||
identity.keys[0].publicKeyHex,
|
||||
];
|
||||
await platformService.dbExec(sql, params);
|
||||
|
||||
// Update active identity in the active_identity table instead of settings
|
||||
await platformService.updateActiveDid(identity.did);
|
||||
|
||||
await platformService.insertNewDidIntoSettings(identity.did);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -715,7 +774,8 @@ export const registerSaveAndActivatePasskey = async (
|
||||
): Promise<Account> => {
|
||||
const account = await registerAndSavePasskey(keyName);
|
||||
const platformService = await getPlatformService();
|
||||
await platformService.updateDefaultSettings({ activeDid: account.did });
|
||||
// Update active identity in the active_identity table instead of settings
|
||||
await platformService.updateActiveDid(account.did);
|
||||
await platformService.updateDidSpecificSettings(account.did, {
|
||||
isRegistered: false,
|
||||
});
|
||||
@@ -928,11 +988,6 @@ export async function importFromMnemonic(
|
||||
): Promise<void> {
|
||||
const mne: string = mnemonic.trim().toLowerCase();
|
||||
|
||||
// Check if this is Test User #0
|
||||
const TEST_USER_0_MNEMONIC =
|
||||
"rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage";
|
||||
const isTestUser0 = mne === TEST_USER_0_MNEMONIC;
|
||||
|
||||
// Derive address and keys from mnemonic
|
||||
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
|
||||
|
||||
@@ -947,85 +1002,85 @@ export async function importFromMnemonic(
|
||||
|
||||
// Save the new identity
|
||||
await saveNewIdentity(newId, mne, derivationPath);
|
||||
}
|
||||
|
||||
// Set up Test User #0 specific settings
|
||||
if (isTestUser0) {
|
||||
// Set up Test User #0 specific settings with enhanced error handling
|
||||
const platformService = await getPlatformService();
|
||||
/**
|
||||
* Checks if an account with the given DID already exists in the database
|
||||
*
|
||||
* @param did - The DID to check for duplicates
|
||||
* @returns Promise<boolean> - True if account already exists, false otherwise
|
||||
* @throws Error if database query fails
|
||||
*/
|
||||
export async function checkForDuplicateAccount(did: string): Promise<boolean>;
|
||||
|
||||
try {
|
||||
// First, ensure the DID-specific settings record exists
|
||||
await platformService.insertNewDidIntoSettings(newId.did);
|
||||
/**
|
||||
* Checks if an account with the given DID already exists in the database
|
||||
*
|
||||
* @param mnemonic - The mnemonic phrase to derive DID from
|
||||
* @param derivationPath - The derivation path to use
|
||||
* @returns Promise<boolean> - True if account already exists, false otherwise
|
||||
* @throws Error if database query fails
|
||||
*/
|
||||
export async function checkForDuplicateAccount(
|
||||
mnemonic: string,
|
||||
derivationPath: string,
|
||||
): Promise<boolean>;
|
||||
|
||||
// Then update with Test User #0 specific settings
|
||||
await platformService.updateDidSpecificSettings(newId.did, {
|
||||
firstName: "User Zero",
|
||||
isRegistered: true,
|
||||
});
|
||||
/**
|
||||
* Implementation of checkForDuplicateAccount with overloaded signatures
|
||||
*/
|
||||
export async function checkForDuplicateAccount(
|
||||
didOrMnemonic: string,
|
||||
derivationPath?: string,
|
||||
): Promise<boolean> {
|
||||
let didToCheck: string;
|
||||
|
||||
// Verify the settings were saved correctly
|
||||
const verificationResult = await platformService.dbQuery(
|
||||
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
|
||||
[newId.did],
|
||||
);
|
||||
if (derivationPath) {
|
||||
// Derive the DID from mnemonic and derivation path
|
||||
const [address, privateHex, publicHex] = deriveAddress(
|
||||
didOrMnemonic.trim().toLowerCase(),
|
||||
derivationPath,
|
||||
);
|
||||
|
||||
if (verificationResult?.values?.length) {
|
||||
const settings = verificationResult.values[0];
|
||||
const firstName = settings[0];
|
||||
const isRegistered = settings[1];
|
||||
const newId = newIdentifier(address, privateHex, publicHex, derivationPath);
|
||||
didToCheck = newId.did;
|
||||
} else {
|
||||
// Use the provided DID directly
|
||||
didToCheck = didOrMnemonic;
|
||||
}
|
||||
|
||||
logger.info("[importFromMnemonic] Test User #0 settings verification", {
|
||||
did: newId.did,
|
||||
firstName,
|
||||
isRegistered,
|
||||
expectedFirstName: "User Zero",
|
||||
expectedIsRegistered: true,
|
||||
});
|
||||
// Check if an account with this DID already exists
|
||||
const platformService = await getPlatformService();
|
||||
const existingAccount = await platformService.dbQuery(
|
||||
"SELECT did FROM accounts WHERE did = ?",
|
||||
[didToCheck],
|
||||
);
|
||||
|
||||
// If settings weren't saved correctly, try individual updates
|
||||
if (firstName !== "User Zero" || isRegistered !== 1) {
|
||||
logger.warn(
|
||||
"[importFromMnemonic] Test User #0 settings not saved correctly, retrying with individual updates",
|
||||
);
|
||||
return (existingAccount?.values?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
await platformService.dbExec(
|
||||
"UPDATE settings SET firstName = ? WHERE accountDid = ?",
|
||||
["User Zero", newId.did],
|
||||
);
|
||||
export class PromiseTracker<T> {
|
||||
private _promise: Promise<T>;
|
||||
private _resolved = false;
|
||||
private _value: T | undefined;
|
||||
|
||||
await platformService.dbExec(
|
||||
"UPDATE settings SET isRegistered = ? WHERE accountDid = ?",
|
||||
[1, newId.did],
|
||||
);
|
||||
constructor(promise: Promise<T>) {
|
||||
this._promise = promise.then((value) => {
|
||||
this._resolved = true;
|
||||
this._value = value;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
// Verify again
|
||||
const retryResult = await platformService.dbQuery(
|
||||
"SELECT firstName, isRegistered FROM settings WHERE accountDid = ?",
|
||||
[newId.did],
|
||||
);
|
||||
get isResolved(): boolean {
|
||||
return this._resolved;
|
||||
}
|
||||
|
||||
if (retryResult?.values?.length) {
|
||||
const retrySettings = retryResult.values[0];
|
||||
logger.info(
|
||||
"[importFromMnemonic] Test User #0 settings after retry",
|
||||
{
|
||||
firstName: retrySettings[0],
|
||||
isRegistered: retrySettings[1],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
"[importFromMnemonic] Failed to verify Test User #0 settings - no record found",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[importFromMnemonic] Error setting up Test User #0 settings:",
|
||||
error,
|
||||
);
|
||||
// Don't throw - allow the import to continue even if settings fail
|
||||
}
|
||||
get value(): T | undefined {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
get promise(): Promise<T> {
|
||||
return this._promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,18 +69,18 @@ const deepLinkHandler = new DeepLinkHandler(router);
|
||||
*/
|
||||
const handleDeepLink = async (data: { url: string }) => {
|
||||
const { url } = data;
|
||||
logger.info(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
||||
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
|
||||
|
||||
try {
|
||||
// Wait for router to be ready
|
||||
logger.info(`[Main] ⏳ Waiting for router to be ready...`);
|
||||
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
|
||||
await router.isReady();
|
||||
logger.info(`[Main] ✅ Router is ready, processing deeplink`);
|
||||
logger.debug(`[Main] ✅ Router is ready, processing deeplink`);
|
||||
|
||||
// Process the deeplink
|
||||
logger.info(`[Main] 🚀 Starting deeplink processing`);
|
||||
logger.debug(`[Main] 🚀 Starting deeplink processing`);
|
||||
await deepLinkHandler.handleDeepLink(url);
|
||||
logger.info(`[Main] ✅ Deeplink processed successfully`);
|
||||
logger.debug(`[Main] ✅ Deeplink processed successfully`);
|
||||
} catch (error) {
|
||||
logger.error(`[Main] ❌ Deeplink processing failed:`, {
|
||||
url,
|
||||
@@ -115,25 +115,25 @@ const registerDeepLinkListener = async () => {
|
||||
);
|
||||
|
||||
// Check if Capacitor App plugin is available
|
||||
logger.info(`[Main] 🔍 Checking Capacitor App plugin availability...`);
|
||||
logger.debug(`[Main] 🔍 Checking Capacitor App plugin availability...`);
|
||||
if (!CapacitorApp) {
|
||||
throw new Error("Capacitor App plugin not available");
|
||||
}
|
||||
logger.info(`[Main] ✅ Capacitor App plugin is available`);
|
||||
|
||||
// Check available methods on CapacitorApp
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`[Main] 🔍 Capacitor App plugin methods:`,
|
||||
Object.getOwnPropertyNames(CapacitorApp),
|
||||
);
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`[Main] 🔍 Capacitor App plugin addListener method:`,
|
||||
typeof CapacitorApp.addListener,
|
||||
);
|
||||
|
||||
// Wait for router to be ready first
|
||||
await router.isReady();
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`[Main] ✅ Router is ready, proceeding with listener registration`,
|
||||
);
|
||||
|
||||
@@ -148,9 +148,6 @@ const registerDeepLinkListener = async () => {
|
||||
listenerHandle,
|
||||
);
|
||||
|
||||
// Test the listener registration by checking if it's actually registered
|
||||
logger.info(`[Main] 🧪 Verifying listener registration...`);
|
||||
|
||||
return listenerHandle;
|
||||
} catch (error) {
|
||||
logger.error(`[Main] ❌ Failed to register deeplink listener:`, {
|
||||
|
||||
15
src/main.ts
15
src/main.ts
@@ -13,14 +13,23 @@ const platform = process.env.VITE_PLATFORM || "web";
|
||||
|
||||
logger.info(`[Main] 🚀 Loading TimeSafari for platform: ${platform}`);
|
||||
|
||||
// Log all relevant environment variables for boot-time debugging
|
||||
logger.info("[Main] 🌍 Boot-time environment configuration:", {
|
||||
platform: process.env.VITE_PLATFORM,
|
||||
defaultEndorserApiServer: process.env.VITE_DEFAULT_ENDORSER_API_SERVER,
|
||||
defaultPartnerApiServer: process.env.VITE_DEFAULT_PARTNER_API_SERVER,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Dynamically import the appropriate main entry point
|
||||
if (platform === "capacitor") {
|
||||
logger.info(`[Main] 📱 Loading Capacitor-specific entry point`);
|
||||
logger.debug(`[Main] 📱 Loading Capacitor-specific entry point`);
|
||||
import("./main.capacitor");
|
||||
} else if (platform === "electron") {
|
||||
logger.info(`[Main] 💻 Loading Electron-specific entry point`);
|
||||
logger.debug(`[Main] 💻 Loading Electron-specific entry point`);
|
||||
import("./main.electron");
|
||||
} else {
|
||||
logger.info(`[Main] 🌐 Loading Web-specific entry point`);
|
||||
logger.debug(`[Main] 🌐 Loading Web-specific entry point`);
|
||||
import("./main.web");
|
||||
}
|
||||
|
||||
@@ -285,6 +285,16 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "user-profile",
|
||||
component: () => import("../views/UserProfileView.vue"),
|
||||
},
|
||||
// Catch-all route for 404 errors - must be last
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "not-found",
|
||||
component: () => import("../views/NotFoundView.vue"),
|
||||
meta: {
|
||||
title: "Page Not Found",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const isElectron = window.location.protocol === "file:";
|
||||
@@ -327,7 +337,7 @@ router.onError(errorHandler); // Assign the error handler to the router instance
|
||||
* @param next - Navigation function
|
||||
*/
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
logger.info(`[Router] 🧭 Navigation guard triggered:`, {
|
||||
logger.debug(`[Router] 🧭 Navigation guard triggered:`, {
|
||||
from: _from?.path || "none",
|
||||
to: to.path,
|
||||
name: to.name,
|
||||
@@ -337,6 +347,22 @@ router.beforeEach(async (to, _from, next) => {
|
||||
});
|
||||
|
||||
try {
|
||||
// Log boot-time configuration on first navigation
|
||||
if (!_from) {
|
||||
logger.info(
|
||||
"[Router] 🚀 First navigation detected - logging boot-time configuration:",
|
||||
{
|
||||
platform: process.env.VITE_PLATFORM,
|
||||
defaultEndorserApiServer:
|
||||
process.env.VITE_DEFAULT_ENDORSER_API_SERVER,
|
||||
defaultPartnerApiServer: process.env.VITE_DEFAULT_PARTNER_API_SERVER,
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
targetRoute: to.path,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Skip identity check for routes that handle identity creation manually
|
||||
const skipIdentityRoutes = [
|
||||
"/start",
|
||||
@@ -352,11 +378,11 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.info(`[Router] 🔍 Checking user identity for route: ${to.path}`);
|
||||
logger.debug(`[Router] 🔍 Checking user identity for route: ${to.path}`);
|
||||
|
||||
// Check if user has any identities
|
||||
const allMyDids = await retrieveAccountDids();
|
||||
logger.info(`[Router] 📋 Found ${allMyDids.length} user identities`);
|
||||
logger.debug(`[Router] 📋 Found ${allMyDids.length} user identities`);
|
||||
|
||||
if (allMyDids.length === 0) {
|
||||
logger.info("[Router] ⚠️ No identities found, creating default identity");
|
||||
@@ -366,12 +392,12 @@ router.beforeEach(async (to, _from, next) => {
|
||||
|
||||
logger.info("[Router] ✅ Default identity created successfully");
|
||||
} else {
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`[Router] ✅ User has ${allMyDids.length} identities, proceeding`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`[Router] ✅ Navigation guard passed for: ${to.path}`);
|
||||
logger.debug(`[Router] ✅ Navigation guard passed for: ${to.path}`);
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error("[Router] ❌ Identity creation failed in navigation guard:", {
|
||||
@@ -392,7 +418,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
|
||||
// Add navigation success logging
|
||||
router.afterEach((to, from) => {
|
||||
logger.info(`[Router] ✅ Navigation completed:`, {
|
||||
logger.debug(`[Router] ✅ Navigation completed:`, {
|
||||
from: from?.path || "none",
|
||||
to: to.path,
|
||||
name: to.name,
|
||||
|
||||
@@ -32,6 +32,68 @@ export interface PlatformCapabilities {
|
||||
isNativeApp: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission status for notifications
|
||||
*/
|
||||
export interface PermissionStatus {
|
||||
/** Notification permission status */
|
||||
notifications: "granted" | "denied" | "prompt";
|
||||
/** Exact alarms permission status (Android only) */
|
||||
exactAlarms?: "granted" | "denied" | "prompt";
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of permission request
|
||||
*/
|
||||
export interface PermissionResult {
|
||||
/** Whether notification permission was granted */
|
||||
notifications: boolean;
|
||||
/** Whether exact alarms permission was granted (Android only) */
|
||||
exactAlarms?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of scheduled daily notifications
|
||||
*/
|
||||
export interface NotificationStatus {
|
||||
/** Whether a notification is currently scheduled */
|
||||
isScheduled: boolean;
|
||||
/** Scheduled time in "HH:mm" format (24-hour) */
|
||||
scheduledTime?: string;
|
||||
/** Last time the notification was triggered (ISO string) */
|
||||
lastTriggered?: string;
|
||||
/** Current permission status */
|
||||
permissions: PermissionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for scheduling a daily notification
|
||||
*/
|
||||
export interface ScheduleOptions {
|
||||
/** Time in "HH:mm" format (24-hour) in local time */
|
||||
time: string;
|
||||
/** Notification title */
|
||||
title: string;
|
||||
/** Notification body text */
|
||||
body: string;
|
||||
/** Whether to play sound (default: true) */
|
||||
sound?: boolean;
|
||||
/** Notification priority */
|
||||
priority?: "high" | "normal" | "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for native fetcher background operations
|
||||
*/
|
||||
export interface NativeFetcherConfig {
|
||||
/** API server URL */
|
||||
apiServer: string;
|
||||
/** JWT token for authentication */
|
||||
jwt: string;
|
||||
/** Array of starred plan handle IDs */
|
||||
starredPlanHandleIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-agnostic interface for handling platform-specific operations.
|
||||
* Provides a common API for file system operations, camera interactions,
|
||||
@@ -155,6 +217,16 @@ export interface PlatformService {
|
||||
*/
|
||||
dbGetOneRow(sql: string, params?: unknown[]): Promise<unknown[] | undefined>;
|
||||
|
||||
/**
|
||||
* Not recommended except for debugging.
|
||||
* Return the raw result of a SQL query.
|
||||
*
|
||||
* @param sql - The SQL query to execute
|
||||
* @param params - The parameters to pass to the query
|
||||
* @returns Promise resolving to the raw query result, or undefined if no results
|
||||
*/
|
||||
dbRawQuery(sql: string, params?: unknown[]): Promise<unknown | undefined>;
|
||||
|
||||
// Database utility methods
|
||||
/**
|
||||
* Generates an INSERT SQL statement for a given model and table.
|
||||
@@ -173,6 +245,7 @@ export interface PlatformService {
|
||||
* @returns Promise that resolves when the update is complete
|
||||
*/
|
||||
updateDefaultSettings(settings: Record<string, unknown>): Promise<void>;
|
||||
updateActiveDid(did: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Inserts a new DID into the settings table.
|
||||
@@ -198,6 +271,58 @@ export interface PlatformService {
|
||||
*/
|
||||
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
|
||||
|
||||
// Daily notification operations
|
||||
/**
|
||||
* Get the status of scheduled daily notifications
|
||||
* @returns Promise resolving to notification status, or null if not supported
|
||||
*/
|
||||
getDailyNotificationStatus(): Promise<NotificationStatus | null>;
|
||||
|
||||
/**
|
||||
* Check notification permissions
|
||||
* @returns Promise resolving to permission status, or null if not supported
|
||||
*/
|
||||
checkNotificationPermissions(): Promise<PermissionStatus | null>;
|
||||
|
||||
/**
|
||||
* Request notification permissions
|
||||
* @returns Promise resolving to permission result, or null if not supported
|
||||
*/
|
||||
requestNotificationPermissions(): Promise<PermissionResult | null>;
|
||||
|
||||
/**
|
||||
* Schedule a daily notification
|
||||
* @param options - Notification scheduling options
|
||||
* @returns Promise that resolves when scheduled, or rejects if not supported
|
||||
*/
|
||||
scheduleDailyNotification(options: ScheduleOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cancel scheduled daily notification
|
||||
* @returns Promise that resolves when cancelled, or rejects if not supported
|
||||
*/
|
||||
cancelDailyNotification(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Configure native fetcher for background operations
|
||||
* @param config - Native fetcher configuration
|
||||
* @returns Promise that resolves when configured, or null if not supported
|
||||
*/
|
||||
configureNativeFetcher(config: NativeFetcherConfig): Promise<void | null>;
|
||||
|
||||
/**
|
||||
* Update starred plans for background fetcher
|
||||
* @param plans - Starred plan IDs
|
||||
* @returns Promise that resolves when updated, or null if not supported
|
||||
*/
|
||||
updateStarredPlans(plans: { planIds: string[] }): Promise<void | null>;
|
||||
|
||||
/**
|
||||
* Open the app's notification settings in the system settings
|
||||
* @returns Promise that resolves when the settings page is opened, or null if not supported
|
||||
*/
|
||||
openAppNotificationSettings(): Promise<void | null>;
|
||||
|
||||
// --- PWA/Web-only methods (optional, only implemented on web) ---
|
||||
/**
|
||||
* Registers the service worker for PWA support (web only)
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* ProfileService - Handles user profile operations and API calls
|
||||
* Extracted from AccountViewView.vue to improve separation of concerns
|
||||
*/
|
||||
|
||||
import { AxiosInstance, AxiosError } from "axios";
|
||||
import { UserProfile } from "@/libs/partnerServer";
|
||||
import { UserProfileResponse } from "@/interfaces/accountView";
|
||||
import { getHeaders, errorStringForLog } from "@/libs/endorserServer";
|
||||
import { handleApiError } from "./api";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||
|
||||
/**
|
||||
* Profile data interface
|
||||
*/
|
||||
export interface ProfileData {
|
||||
description: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
includeLocation: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile service class
|
||||
*/
|
||||
export class ProfileService {
|
||||
private axios: AxiosInstance;
|
||||
private partnerApiServer: string;
|
||||
|
||||
constructor(axios: AxiosInstance, partnerApiServer: string) {
|
||||
this.axios = axios;
|
||||
this.partnerApiServer = partnerApiServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user profile from the server
|
||||
* @param activeDid - The user's DID
|
||||
* @returns ProfileData or null if profile doesn't exist
|
||||
*/
|
||||
async loadProfile(activeDid: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await this.axios.get<UserProfileResponse>(
|
||||
`${this.partnerApiServer}/api/partner/userProfileForIssuer/${activeDid}`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const data = response.data.data;
|
||||
const profileData: ProfileData = {
|
||||
description: data.description || "",
|
||||
latitude: data.locLat || 0,
|
||||
longitude: data.locLon || 0,
|
||||
includeLocation: !!(data.locLat && data.locLon),
|
||||
};
|
||||
return profileData;
|
||||
} else {
|
||||
throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.UNABLE_TO_LOAD_PROFILE);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isApiError(error) && error.response?.status === 404) {
|
||||
// Profile doesn't exist yet - this is normal
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.error("Error loading profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfileForIssuer");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user profile to the server
|
||||
* @param activeDid - The user's DID
|
||||
* @param profileData - The profile data to save
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
async saveProfile(
|
||||
activeDid: string,
|
||||
profileData: ProfileData,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const payload: UserProfile = {
|
||||
description: profileData.description,
|
||||
issuerDid: activeDid,
|
||||
};
|
||||
|
||||
// Add location data if location is included
|
||||
if (
|
||||
profileData.includeLocation &&
|
||||
profileData.latitude &&
|
||||
profileData.longitude
|
||||
) {
|
||||
payload.locLat = profileData.latitude;
|
||||
payload.locLon = profileData.longitude;
|
||||
}
|
||||
|
||||
const response = await this.axios.post(
|
||||
`${this.partnerApiServer}/api/partner/userProfile`,
|
||||
payload,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.status === 201) {
|
||||
return true;
|
||||
} else {
|
||||
logger.error("Error saving profile:", response);
|
||||
throw new Error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_NOT_SAVED);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error saving profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfile");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user profile from the server
|
||||
* @param activeDid - The user's DID
|
||||
* @returns true if successful, false otherwise
|
||||
*/
|
||||
async deleteProfile(activeDid: string): Promise<boolean> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const url = `${this.partnerApiServer}/api/partner/userProfile`;
|
||||
const response = await this.axios.delete(url, { headers });
|
||||
|
||||
if (response.status === 204 || response.status === 200) {
|
||||
logger.info("Profile deleted successfully");
|
||||
return true;
|
||||
} else {
|
||||
logger.error("Unexpected response status when deleting profile:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
});
|
||||
throw new Error(
|
||||
`Profile not deleted - HTTP ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.isApiError(error) && error.response) {
|
||||
const response = error.response;
|
||||
logger.error("API error deleting profile:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: response.data,
|
||||
url: this.getErrorUrl(error),
|
||||
});
|
||||
|
||||
// Handle specific HTTP status codes
|
||||
if (response.status === 204) {
|
||||
logger.debug("Profile deleted successfully (204 No Content)");
|
||||
return true; // 204 is success for DELETE operations
|
||||
} else if (response.status === 404) {
|
||||
logger.warn("Profile not found - may already be deleted");
|
||||
return true; // Consider this a success if profile doesn't exist
|
||||
} else if (response.status === 400) {
|
||||
logger.error("Bad request when deleting profile:", response.data);
|
||||
const errorMessage =
|
||||
typeof response.data === "string"
|
||||
? response.data
|
||||
: response.data?.message || "Bad request";
|
||||
throw new Error(`Profile deletion failed: ${errorMessage}`);
|
||||
} else if (response.status === 401) {
|
||||
logger.error("Unauthorized to delete profile");
|
||||
throw new Error("You are not authorized to delete this profile");
|
||||
} else if (response.status === 403) {
|
||||
logger.error("Forbidden to delete profile");
|
||||
throw new Error("You are not allowed to delete this profile");
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("Error deleting profile:", errorStringForLog(error));
|
||||
handleApiError(error as AxiosError, "/api/partner/userProfile");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile location
|
||||
* @param profileData - Current profile data
|
||||
* @param latitude - New latitude
|
||||
* @param longitude - New longitude
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
updateProfileLocation(
|
||||
profileData: ProfileData,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
): ProfileData {
|
||||
return {
|
||||
...profileData,
|
||||
latitude,
|
||||
longitude,
|
||||
includeLocation: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle location inclusion in profile
|
||||
* @param profileData - Current profile data
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
toggleProfileLocation(profileData: ProfileData): ProfileData {
|
||||
const includeLocation = !profileData.includeLocation;
|
||||
return {
|
||||
...profileData,
|
||||
latitude: includeLocation ? profileData.latitude : 0,
|
||||
longitude: includeLocation ? profileData.longitude : 0,
|
||||
includeLocation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear profile location
|
||||
* @param profileData - Current profile data
|
||||
* @returns Updated profile data
|
||||
*/
|
||||
clearProfileLocation(profileData: ProfileData): ProfileData {
|
||||
return {
|
||||
...profileData,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
includeLocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset profile to default state
|
||||
* @returns Default profile data
|
||||
*/
|
||||
getDefaultProfile(): ProfileData {
|
||||
return {
|
||||
description: "",
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
includeLocation: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for API errors with proper typing
|
||||
*/
|
||||
private isApiError(error: unknown): error is {
|
||||
response?: {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
data?: { message?: string } | string;
|
||||
};
|
||||
} {
|
||||
return typeof error === "object" && error !== null && "response" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error URL safely from error object
|
||||
*/
|
||||
private getErrorUrl(error: unknown): string | undefined {
|
||||
if (this.isAxiosError(error)) {
|
||||
return error.config?.url;
|
||||
}
|
||||
if (this.isApiError(error) && this.hasConfigProperty(error)) {
|
||||
const config = this.getConfigProperty(error);
|
||||
return config?.url;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if error has config property
|
||||
*/
|
||||
private hasConfigProperty(
|
||||
error: unknown,
|
||||
): error is { config?: { url?: string } } {
|
||||
return typeof error === "object" && error !== null && "config" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract config property from error
|
||||
*/
|
||||
private getConfigProperty(error: {
|
||||
config?: { url?: string };
|
||||
}): { url?: string } | undefined {
|
||||
return error.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for AxiosError
|
||||
*/
|
||||
private isAxiosError(error: unknown): error is AxiosError {
|
||||
return error instanceof AxiosError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a ProfileService instance
|
||||
*/
|
||||
export function createProfileService(
|
||||
axios: AxiosInstance,
|
||||
partnerApiServer: string,
|
||||
): ProfileService {
|
||||
return new ProfileService(axios, partnerApiServer);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
// Generate a short random ID for this scanner instance
|
||||
this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
this.options = options ?? {};
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
|
||||
{
|
||||
...this.options,
|
||||
@@ -49,7 +49,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.context = this.canvas.getContext("2d", { willReadFrequently: true });
|
||||
this.video = document.createElement("video");
|
||||
this.video.setAttribute("playsinline", "true"); // Required for iOS
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] DOM elements created successfully`,
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.cameraStateListeners.forEach((listener) => {
|
||||
try {
|
||||
listener.onStateChange(state, message);
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`,
|
||||
{
|
||||
state,
|
||||
@@ -89,7 +89,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
async checkPermissions(): Promise<boolean> {
|
||||
try {
|
||||
this.updateCameraState("initializing", "Checking camera permissions...");
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
const permissions = await navigator.permissions.query({
|
||||
name: "camera" as PermissionName,
|
||||
});
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`,
|
||||
permissions.state,
|
||||
);
|
||||
@@ -165,7 +165,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
"initializing",
|
||||
"Requesting camera permissions...",
|
||||
);
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`,
|
||||
);
|
||||
|
||||
@@ -175,7 +175,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
(device) => device.kind === "videoinput",
|
||||
);
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
|
||||
count: videoDevices.length,
|
||||
devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })),
|
||||
userAgent: navigator.userAgent,
|
||||
@@ -188,7 +188,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
}
|
||||
|
||||
// Try to get a stream with specific constraints
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`,
|
||||
{
|
||||
facingMode: "environment",
|
||||
@@ -210,7 +210,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
// Stop the test stream immediately
|
||||
stream.getTracks().forEach((track) => {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
|
||||
kind: track.kind,
|
||||
label: track.label,
|
||||
readyState: track.readyState,
|
||||
@@ -275,12 +275,12 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
async isSupported(): Promise<boolean> {
|
||||
try {
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Checking browser support...`,
|
||||
);
|
||||
// Check for secure context first
|
||||
if (!window.isSecureContext) {
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`,
|
||||
);
|
||||
return false;
|
||||
@@ -300,7 +300,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
(device) => device.kind === "videoinput",
|
||||
);
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Device support check:`, {
|
||||
hasSecureContext: window.isSecureContext,
|
||||
hasMediaDevices: !!navigator.mediaDevices,
|
||||
hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia,
|
||||
@@ -379,7 +379,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
// Log scan attempt every 100 frames or 1 second
|
||||
if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
|
||||
attempt: this.scanAttempts,
|
||||
dimensions: {
|
||||
width: this.canvas.width,
|
||||
@@ -421,7 +421,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
!code.data ||
|
||||
code.data.length === 0;
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
|
||||
data: code.data,
|
||||
location: code.location,
|
||||
attempts: this.scanAttempts,
|
||||
@@ -512,13 +512,13 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.scanAttempts = 0;
|
||||
this.lastScanTime = Date.now();
|
||||
this.updateCameraState("initializing", "Starting camera...");
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
|
||||
this.options,
|
||||
);
|
||||
|
||||
// Get camera stream with options
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
|
||||
);
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -531,7 +531,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
this.updateCameraState("active", "Camera is active");
|
||||
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
|
||||
tracks: this.stream.getTracks().map((t) => ({
|
||||
kind: t.kind,
|
||||
label: t.label,
|
||||
@@ -550,14 +550,14 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
this.video.style.display = "none";
|
||||
}
|
||||
await this.video.play();
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Video element started playing`,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit stream to component
|
||||
this.events.emit("stream", this.stream);
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
|
||||
|
||||
// Start QR code scanning
|
||||
this.scanQRCode();
|
||||
@@ -595,7 +595,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
}
|
||||
|
||||
try {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
|
||||
scanAttempts: this.scanAttempts,
|
||||
duration: Date.now() - this.lastScanTime,
|
||||
});
|
||||
@@ -604,7 +604,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Animation frame cancelled`,
|
||||
);
|
||||
}
|
||||
@@ -613,13 +613,13 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
if (this.video) {
|
||||
this.video.pause();
|
||||
this.video.srcObject = null;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Video element stopped`);
|
||||
}
|
||||
|
||||
// Stop all tracks in the stream
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
|
||||
kind: track.kind,
|
||||
label: track.label,
|
||||
readyState: track.readyState,
|
||||
@@ -631,7 +631,7 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
// Emit stream stopped event
|
||||
this.events.emit("stream", null);
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -643,17 +643,17 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
throw error;
|
||||
} finally {
|
||||
this.isScanning = false;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener: ScanListener): void {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
|
||||
this.scanListener = listener;
|
||||
}
|
||||
|
||||
onStream(callback: (stream: MediaStream | null) => void): void {
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Adding stream event listener`,
|
||||
);
|
||||
this.events.on("stream", callback);
|
||||
@@ -661,24 +661,24 @@ export class WebInlineQRScanner implements QRScannerService {
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
try {
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
|
||||
await this.stopScan();
|
||||
this.events.removeAllListeners();
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
|
||||
|
||||
// Clean up DOM elements
|
||||
if (this.video) {
|
||||
this.video.remove();
|
||||
this.video = null;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Video element removed`);
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.remove();
|
||||
this.canvas = null;
|
||||
logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
|
||||
logger.debug(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
|
||||
}
|
||||
this.context = null;
|
||||
logger.error(
|
||||
logger.debug(
|
||||
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user