Compare commits

..

11 Commits

70 changed files with 1497 additions and 1801 deletions

View File

@@ -21,7 +21,7 @@ alwaysApply: false
## Purpose
All interactions must _increase the human's competence over time_ while
All interactions must *increase the human's competence over time* while
completing the task efficiently. The model may handle menial work and memory
extension, but must also promote learning, autonomy, and healthy work habits.
The model should also **encourage human interaction and collaboration** rather
@@ -31,7 +31,7 @@ machine-driven steps.
## Principles
1. Competence over convenience: finish the task _and_ leave the human more
1. Competence over convenience: finish the task *and* leave the human more
capable next time.
@@ -75,7 +75,7 @@ assumptions if unanswered.
### timebox_minutes
_integer or null_ — When set to a positive integer (e.g., `5`), this acts
*integer or null* — When set to a positive integer (e.g., `5`), this acts
as a **time budget** guiding the model to prioritize delivering the most
essential parts of the task within that constraint.
@@ -91,7 +91,7 @@ Behavior when set:
3. **Signal Skipped Depth** — Omitted details should be listed under
_Deferred for depth_.
*Deferred for depth*.
4. **Order by Value** — Start with blocking or high-value items, then
@@ -198,7 +198,7 @@ Default: Doer + short Mentor notes.
## Self-Check (model, before responding)
- [ ] Task done _and_ at least one competence lever included (≤120 words
- [ ] Task done *and* at least one competence lever included (≤120 words
total)
- [ ] At least one collaboration/discussion hook present
- [ ] Output follows the **Output Contract** sections

View File

@@ -53,7 +53,7 @@ evidence-backed steps**.
- **Verifiable Outputs**: Include expected results, status codes, or
error messages
- **Cite evidence** for _Works/Doesn't_ items (timestamps, filenames,
- **Cite evidence** for *Works/Doesn't* items (timestamps, filenames,
line numbers, IDs/status codes, or logs).
## Required Sections
@@ -181,8 +181,8 @@ Before publishing, verify:
---
**Status**: 🚢 ACTIVE — General ruleset extending _Base Context — Human
Competence First_
**Status**: 🚢 ACTIVE — General ruleset extending *Base Context — Human
Competence First*
**Priority**: Critical
**Estimated Effort**: Ongoing reference

View File

@@ -16,7 +16,7 @@ language: Match repository languages and conventions
where it occurs; avoid new layers, indirection, or patterns unless
strictly necessary.
2. **Keep scope tight.** Implement only what is needed to satisfy the
acceptance criteria and tests for _this_ issue.
acceptance criteria and tests for *this* issue.
3. **Avoid speculative abstractions.** Use the **Rule of Three**:
don't extract helpers/patterns until the third concrete usage proves
the shape.
@@ -29,7 +29,7 @@ language: Match repository languages and conventions
7. **Targeted tests only.** Add the smallest set of tests that prove
the fix and guard against regression; don't rewrite suites.
8. **Document the "why enough."** Include a one-paragraph note
explaining why this minimal solution is sufficient _now_.
explaining why this minimal solution is sufficient *now*.
## Future-Proofing Requires Evidence + Discussion

View File

@@ -9,8 +9,8 @@ alwaysApply: false
**Date**: 2025-08-19
**Status**: 🎯 **ACTIVE** - Asset management guidelines
_Scope: Assets Only (icons, splashes, image pipelines) — not overall build
orchestration_
*Scope: Assets Only (icons, splashes, image pipelines) — not overall build
orchestration*
## Intent

View File

@@ -40,7 +40,7 @@ feature development, issue investigations, ADRs, and documentation**.
`2025-08-17`).
- Avoid ambiguous terms like _recently_, _last month_, or _soon_.
- Avoid ambiguous terms like *recently*, *last month*, or *soon*.
- For time-based experiments (e.g., A/B tests), always include:

View File

@@ -19,7 +19,7 @@
- Optionally provide UTC alongside if context requires cross-team clarity.
- When interpreting relative terms like _now_, _today_, _last week_:
- When interpreting relative terms like *now*, *today*, *last week*:
- Resolve them against the **developer's current time**.

View File

@@ -1,96 +0,0 @@
---
title: Documentation, References, and Model Agent Use
version: 1.1
alwaysApply: true
scope: code, project-plans
---
# Directive on Documentation, References, and Model Agent Use in Code and Project Plans
To ensure clarity, efficiency, and high-value documentation within code and project plans—and to leverage **model agents** (AI- or automation-based assistants) effectively—contributors must follow these rules:
---
## 1. Documentation and References Must Add Clear Value
- Only include documentation, comments, or reference links when they provide _new, meaningful information_ that assists understanding or decision-making.
- Avoid duplicating content already obvious in the codebase, version history, or linked project documents.
---
## 2. Eliminate Redundant or Noisy References
- Remove references that serve no purpose beyond filling space.
- Model agents may automatically flag and suggest removal of trivial references (e.g., links to unchanged boilerplate or self-evident context).
---
## 3. Explicit Role of Model Agents
Model agents are **active participants** in documentation quality control. Their tasks include:
- **Relevance Evaluation**: Automatically analyze references for their substantive contribution before inclusion.
- **Redundancy Detection**: Flag duplicate or trivial references across commits, files, or tasks.
- **Context Linking**: Suggest appropriate higher-level docs (designs, ADRs, meeting notes) when a code change touches multi-stage or cross-team items.
- **Placement Optimization**: Recommend centralization of references (e.g., in plan overviews, ADRs, or merge commit messages) rather than scattered low-value inline references.
- **Consistency Monitoring**: Ensure references align with team standards (e.g., ADR template, architecture repo, or external policy documents).
Contributors must treat agent recommendations as **first-pass reviews** but remain accountable for final human judgment.
---
## 4. Contextual References for Complex Items
- Use **centralized references** for multi-stage features (e.g., architectural docs, research threads).
- Keep inline code comments light; push broader context into centralized documents.
- Model agents may auto-summarize complex chains of discussion and attach them as a single reference point.
---
## 5. Centralization of Broader Context
- Store overarching context (design docs, proposals, workflows) in accessible, well-indexed places.
- Model agents should assist by **generating reference maps** that track where docs are cited across the codebase.
---
## 6. Focused Documentation
- Documentation should explain **why** and **how** decisions are made, not just what was changed.
- Model agents can auto-generate first-pass explanations from commit metadata, diffs, and linked issues—but humans must refine them for accuracy and intent.
---
## 7. Review and Accountability
- Reviewers and team leads must reject submissions containing unnecessary or low-quality documentation.
- Model agent outputs are aids, not replacements—contributors remain responsible for **final clarity and relevance**.
---
## 8. Continuous Improvement and Agent Feedback Loops
- Encourage iterative development of model agents so their evaluations become more precise over time.
- Contributions should include **feedback on agent suggestions** (e.g., accepted, rejected, or corrected) to train better future outputs.
- Agents should log patterns of “rejected” suggestions for refinement.
---
## 9. Workflow Overview (Mermaid Diagram)
```mermaid
flowchart TD
A[Contributor] -->|Writes Code & Draft Docs| B[Model Agent]
B -->|Evaluates References| C{Relevant?}
C -->|Yes| D[Suggest Placement & Context Links]
C -->|No| E[Flag Redundancy / Noise]
D --> F[Contributor Refines Docs]
E --> F
F --> G[Reviewer]
G -->|Approves / Requests Revisions| H[Final Documentation]
G -->|Feedback on Agent Suggestions| B
```
---
✅ **Outcome:** By integrating disciplined contributor standards with **model agent augmentation**, the team achieves documentation that is consistently _relevant, concise, centralized, and decision-focused_. AI ensures coverage and noise reduction, while humans ensure precision and judgment.

View File

@@ -192,7 +192,6 @@ Summary of key concepts and skills.
Where to apply this knowledge next.
```
- [ ] Integration tests
- [ ] E2E tests

View File

@@ -16,10 +16,9 @@ inherits: base_context.mdc
**Author**: System/Shared
**Date**: 2025-08-21 (UTC)
**Status**: 🚢 ACTIVE — General ruleset extending _Base Context — Human Competence First_
**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.
@@ -27,11 +26,9 @@ inherits: base_context.mdc
---
## 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:
@@ -40,11 +37,10 @@ Produce a **developer-grade, reproducible guide** for any technical topic that o
- **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/Doesnt_ items (timestamps, filenames, line numbers, IDs/status codes, or logs).
- Cite **evidence** for *Works/Doesnt* 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 Contracts **Objective → Result → Use/Run** headers:
1. **Context & Scope**
@@ -56,9 +52,9 @@ Follow this exact order **after** the Base Contracts **Objective → Result
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_).
- **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)**
@@ -73,19 +69,16 @@ Follow this exact order **after** the Base Contracts **Objective → Result
- 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.
> - *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 / Dont (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**.
@@ -93,7 +86,6 @@ Follow this exact order **after** the Base Contracts **Objective → Result
- **Dont** 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.
@@ -104,7 +96,6 @@ Follow this exact order **after** the Base Contracts **Objective → Result
- [ ] 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)
@@ -141,46 +132,37 @@ Follow this exact order **after** the Base Contracts **Objective → Result
```
## 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 Doesnt (Evidence & Hypotheses)
- ❌ <short failure> at `<component/endpoint/file>`
- **Time**: <YYYY-MM-DDTHH:MMZ>
- **Evidence**: <snippet/id/status>
@@ -188,45 +170,37 @@ Follow this exact order **after** the Base Contracts **Objective → Result
- **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>
- *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).
- 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`)

View File

@@ -82,7 +82,6 @@ common investigation pitfalls.
### **Safe Diagnosis Commands**
✅ **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
@@ -91,7 +90,6 @@ common investigation pitfalls.
- `grep_search` - Text pattern searching
❌ **Never use during diagnosis:**
- `npm run build:web` - Blocks chat
- `npm run build:electron` - Blocks chat
- `npm run build:capacitor` - Blocks chat

View File

@@ -36,7 +36,6 @@ that are essential for all AI interactions.
**This meta-rule enforces current workflow mode constraints for all interactions:**
### **Current Workflow State**
```json
{
"workflowState": {
@@ -63,31 +62,26 @@ that are essential for all AI interactions.
### **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

View File

@@ -51,7 +51,6 @@ providing technical descriptions.
## When to Use
**Use this meta-rule when**:
- Writing new documentation
- Updating existing documentation
- Creating technical guides
@@ -108,7 +107,6 @@ providing technical descriptions.
### **Document Structure**
**Mandatory Sections**:
- **Overview**: Clear purpose and scope with educational context
- **Why This Matters**: Business value and user benefit explanation
- **Core Concepts**: Fundamental understanding before implementation
@@ -118,7 +116,6 @@ providing technical descriptions.
- **Next Steps**: Where to go from here
**Optional Sections**:
- **Background**: Historical context and evolution
- **Alternatives**: Other approaches and trade-offs
- **Advanced Topics**: Deep dive into complex scenarios
@@ -127,7 +124,6 @@ providing technical descriptions.
### **Writing Style**
**Educational Approach**:
- **Conversational tone**: Write as if explaining to a colleague
- **Progressive disclosure**: Start simple, add complexity gradually
- **Active voice**: "You can do this" not "This can be done"
@@ -135,7 +131,6 @@ providing technical descriptions.
- **Analogies**: Use familiar concepts to explain complex ideas
**Technical Accuracy**:
- **Precise language**: Use exact technical terms consistently
- **Code examples**: Working, tested code snippets
- **Version information**: Specify applicable versions and platforms
@@ -144,7 +139,6 @@ providing technical descriptions.
### **Content Quality Standards**
**Educational Value**:
- [ ] **Concept clarity**: Reader understands the fundamental idea
- [ ] **Context relevance**: Reader knows when to apply the knowledge
- [ ] **Practical application**: Reader can implement the solution
@@ -152,7 +146,6 @@ providing technical descriptions.
- [ ] **Next steps**: Reader knows where to continue learning
**Technical Accuracy**:
- [ ] **Fact verification**: All technical details are correct
- [ ] **Code validation**: Examples compile and run correctly
- [ ] **Version compatibility**: Platform and version requirements clear
@@ -190,7 +183,6 @@ providing technical descriptions.
### **Review Checklist**
**Educational Quality**:
- [ ] **Clear learning objective**: What will the reader learn?
- [ ] **Appropriate complexity**: Matches target audience knowledge
- [ ] **Progressive disclosure**: Information builds logically
@@ -198,7 +190,6 @@ providing technical descriptions.
- [ ] **Common questions**: Anticipates and answers reader questions
**Technical Quality**:
- [ ] **Accuracy**: All technical details verified
- [ ] **Completeness**: Covers all necessary information
- [ ] **Consistency**: Terminology and formatting consistent

View File

@@ -9,31 +9,26 @@ alwaysApply: false
**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
@@ -62,7 +57,6 @@ flowchart TD
## Interfaces & Contracts
### Test Results Structure
| Component | Format | Content | Validation |
|---|---|---|---|
| Error Context | Markdown | Page snapshot in YAML | Verify DOM state matches test expectations |
@@ -71,7 +65,6 @@ flowchart TD
| 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 |
@@ -81,7 +74,6 @@ flowchart TD
## 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
@@ -91,7 +83,6 @@ 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
@@ -101,7 +92,6 @@ 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
@@ -111,7 +101,6 @@ 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
@@ -121,7 +110,6 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
```
## 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
@@ -138,7 +126,6 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
- **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"
@@ -152,14 +139,12 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
- **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 |
@@ -167,25 +152,21 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
| 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
@@ -197,7 +178,6 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
**Priority**: High
**Maintainer**: Development team
**Next Review**: 2025-09-21
# Playwright Test Investigation — Harbor Pilot Directive
**Author**: Matthew Raymer
@@ -205,31 +185,26 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
**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
@@ -258,7 +233,6 @@ flowchart TD
## Interfaces & Contracts
### Test Results Structure
| Component | Format | Content | Validation |
|---|---|---|---|
| Error Context | Markdown | Page snapshot in YAML | Verify DOM state matches test expectations |
@@ -267,7 +241,6 @@ flowchart TD
| 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 |
@@ -277,7 +250,6 @@ flowchart TD
## 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
@@ -287,7 +259,6 @@ 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
@@ -297,7 +268,6 @@ 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
@@ -307,7 +277,6 @@ 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
@@ -317,7 +286,6 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
```
## 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
@@ -334,7 +302,6 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
- **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"
@@ -348,14 +315,12 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
- **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 |
@@ -363,25 +328,21 @@ await page.locator('div[role="alert"]:has-text("Register") button:has-text("Yes"
| 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

View File

@@ -73,7 +73,7 @@
### Avoid
- Vague: _improved, enhanced, better_
- Vague: *improved, enhanced, better*
- Trivialities: tiny docs, one-liners, pure lint cleanups (separate,

View File

@@ -18,16 +18,16 @@ npm run lint-fix || {
exit 1
}
# Build Architecture Guard - DISABLED
# 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
# }
# 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 "✅ All pre-commit checks passed!"

View File

@@ -5,28 +5,23 @@
#
. "$(dirname -- "$0")/_/husky.sh"
echo "🔍 Pre-push checks..."
echo "🔍 Running Build Architecture Guard (pre-push)..."
# Build Architecture Guard - DISABLED
# echo "🔍 Running Build Architecture Guard (pre-push)..."
#
# # Get the remote branch we're pushing to
# REMOTE_BRANCH="origin/$(git rev-parse --abbrev-ref HEAD)"
#
# # Check if remote branch exists
# if git show-ref --verify --quiet "refs/remotes/$REMOTE_BRANCH"; then
# RANGE="$REMOTE_BRANCH...HEAD"
# else
# # If remote branch doesn't exist, check last commit
# 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
# }
# Get the remote branch we're pushing to
REMOTE_BRANCH="origin/$(git rev-parse --abbrev-ref HEAD)"
echo "✅ Pre-push checks passed!"
# Check if remote branch exists
if git show-ref --verify --quiet "refs/remotes/$REMOTE_BRANCH"; then
RANGE="$REMOTE_BRANCH...HEAD"
else
# If remote branch doesn't exist, check last commit
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
}

View File

@@ -1,56 +1,27 @@
{
"MD013": false,
"MD033": false,
"MD041": false,
"MD024": {
"siblings_only": true
},
"MD029": {
"style": "ordered"
},
"MD007": {
"indent": 2
},
"MD012": {
"maximum": 1
"MD013": {
"line_length": 80,
"code_blocks": false,
"tables": false,
"headings": false
},
"MD012": true,
"MD022": true,
"MD025": true,
"MD026": {
"punctuation": ".,;:!"
},
"MD030": {
"ul_single": 1,
"ol_single": 1,
"ul_multi": 1,
"ol_multi": 1
},
"MD031": true,
"MD032": true,
"MD034": true,
"MD035": {
"style": "---"
},
"MD036": false,
"MD037": true,
"MD038": true,
"MD039": true,
"MD040": true,
"MD042": true,
"MD043": false,
"MD044": false,
"MD045": true,
"MD046": {
"style": "fenced"
},
"MD047": true,
"MD048": {
"style": "backtick"
},
"MD049": {
"style": "underscore"
},
"MD050": {
"style": "asterisk"
}
"MD009": true,
"MD010": true,
"MD004": { "style": "dash" },
"MD029": { "style": "ordered" },
"MD041": false,
"MD025": false,
"MD024": false,
"MD036": false,
"MD003": false,
"MD040": false,
"MD055": false,
"MD056": false,
"MD034": false,
"MD023": false
}

View File

@@ -93,7 +93,6 @@ The Build Architecture Guard protects your build system by enforcing documentati
#### Protected File Patterns
The guard monitors these sensitive paths:
- `vite.config.*` - Build configuration
- `scripts/**` - Build and utility scripts
- `electron/**` - Desktop application code
@@ -133,7 +132,6 @@ npm run guard:setup
#### Troubleshooting
If you encounter `mapfile: command not found` errors:
```bash
# Ensure script is executable
chmod +x scripts/build-arch-guard.sh
@@ -272,7 +270,6 @@ Start the development server using `npm run build:web:dev` or `npm run build:web
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
@@ -338,18 +335,15 @@ All web build commands use the `./scripts/build-web.sh` script, which provides:
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
@@ -623,8 +617,7 @@ The Electron build process follows a multi-stage approach:
#### **Stage 2: Capacitor Sync**
- Copies web assets to Electron app directory
- Uses Electron-specific Capacitor configuration (not copied from main config)
- Syncs Capacitor plugins for Electron platform
- Syncs Capacitor configuration and plugins
- Prepares native module bindings
#### **Stage 3: TypeScript Compile**
@@ -1263,13 +1256,11 @@ npm run assets:validate
##### What Gets Validated
**Source Assets (Required):**
- `resources/icon.png` - App icon source
- `resources/splash.png` - Splash screen source
- `resources/splash_dark.png` - Dark mode splash source
**Android Resources (Generated):**
- `android/app/src/main/res/drawable/splash.png` - Splash screen drawable
- `android/app/src/main/res/mipmap-*/ic_launcher.png` - App icons for all densities
- `android/app/src/main/res/mipmap-*/ic_launcher_round.png` - Round app icons for all densities
@@ -2714,7 +2705,6 @@ configuration files in the repository.
### 2025-08-21 - Cursor Rules Refactoring and Build System Updates
#### Package Dependencies Updated
- **Added**: `markdownlint-cli2` v0.18.1 - Modern markdown linting with improved performance
- **Added**: `@commitlint/cli` v18.6.1 - Conventional commit message validation
- **Added**: `@commitlint/config-conventional` v18.6.2 - Conventional commit standards
@@ -2722,33 +2712,28 @@ configuration files in the repository.
- **Updated**: `lint-staged` v15.2.2 - Pre-commit linting automation
#### Build Script Improvements
- **Markdown Linting**: Replaced custom markdown scripts with `markdownlint-cli2`
- **Before**: `./scripts/fix-markdown.sh` and `./scripts/validate-markdown.sh`
- **After**: `markdownlint-cli2 --fix` and `markdownlint-cli2`
- **Benefits**: Faster execution, better error reporting, modern markdown standards
#### Lint-Staged Configuration Enhanced
- **Added**: Markdown file linting to pre-commit hooks
- **Pattern**: `*.{md,markdown,mdc}` files now automatically formatted
- **Command**: `markdownlint-cli2 --fix` runs before each commit
- **Coverage**: All markdown files including `.mdc` cursor rules
#### Commit Message Standards
- **Added**: Conventional commit validation via commitlint
- **Configuration**: Extends `@commitlint/config-conventional`
- **Enforcement**: Ensures consistent commit message format across the project
#### Node.js Version Requirements
- **Updated**: Minimum Node.js version requirements for new dependencies
- **markdownlint-cli2**: Requires Node.js >=20
- **Various utilities**: Require Node.js >=18 for modern ES features
#### Build Process Impact
- **No Breaking Changes**: All existing build commands continue to work
- **Improved Quality**: Better markdown formatting and commit message standards
- **Enhanced Automation**: More comprehensive pre-commit validation
@@ -2759,7 +2744,6 @@ configuration files in the repository.
### 2025-08-21 - Commitlint Configuration Refinement
#### Commit Message Validation Improvements
- **Modified**: Commitlint configuration moved from `package.json` to dedicated `commitlint.config.js`
- **Enhanced**: Strict validation rules downgraded from errors to warnings
- **Before**: `subject-case` and `subject-full-stop` rules caused red error messages
@@ -2767,7 +2751,6 @@ configuration files in the repository.
- **Benefit**: Eliminates confusing red error messages while maintaining commit quality guidance
#### Configuration Structure
- **File**: `commitlint.config.js` - Dedicated commitlint configuration
- **Extends**: `@commitlint/config-conventional` - Standard conventional commit rules
- **Custom Rules**:
@@ -2778,7 +2761,6 @@ configuration files in the repository.
- Current: Problematic rules set to warning level (1)
#### User Experience Impact
- **Before**: Red error messages on every push with strict commit rules
- **After**: Yellow warning messages that provide guidance without disruption
- **Workflow**: Commits and pushes continue to work while maintaining quality standards
@@ -2789,7 +2771,6 @@ 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
@@ -2803,27 +2784,23 @@ configuration files in the repository.
- **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

View File

@@ -66,17 +66,14 @@ test-image.tar a1b2c3d4e5f6...
```
### Docs
- [x] **BUILDING.md** updated (sections): Docker deployment
- [x] Troubleshooting updated: Added Docker troubleshooting section
### Rollback
- [x] Verified steps to restore previous behavior:
1. `git revert HEAD`
2. `docker rmi test-image`
3. Restore previous BUILDING.md
```
---

View File

@@ -11,7 +11,7 @@ See [ClickUp](https://sharing.clickup.com/9014278710/l/h/8cmnyhp-174/10573fec74e
Quick start:
- For setup, we recommend [pkgx](https://pkgx.dev), which installs what you need (either automatically or with the `dev` command). Core dependencies are typescript & npm; when building for other platforms, you'll need other things such as those in the pkgx.yaml & BUILDING.md files.
* For setup, we recommend [pkgx](https://pkgx.dev), which installs what you need (either automatically or with the `dev` command). Core dependencies are typescript & npm; when building for other platforms, you'll need other things such as those in the pkgx.yaml & BUILDING.md files.
```bash
npm install
@@ -90,7 +90,6 @@ VITE_LOG_LEVEL=debug npm run dev
See [Logging Configuration Guide](doc/logging-configuration.md) for complete details.
### Quick Usage
```bash
# Run the database clearing script
./scripts/clear-database.sh
@@ -103,19 +102,16 @@ npm run build:web:dev # For Web
### What It Does
#### **Electron (Desktop App)**
- Automatically finds and clears the SQLite database files
- Works on Linux, macOS, and Windows
- Clears all data and forces fresh migrations on next startup
#### **Web Browser**
- Provides instructions for using custom browser data directories
- Shows manual clearing via browser DevTools
- Ensures reliable database clearing without browser complications
### Safety Features
-**Interactive Script**: Guides you through the process
-**Platform Detection**: Automatically detects your OS
-**Clear Instructions**: Step-by-step guidance for each platform
@@ -124,7 +120,6 @@ npm run build:web:dev # For Web
### Manual Commands (if needed)
#### **Electron Database Location**
```bash
# Linux
rm -rf ~/.config/TimeSafari/*
@@ -137,7 +132,6 @@ rmdir /s /q %APPDATA%\TimeSafari
```
#### **Web Browser (Custom Data Directory)**
```bash
# Create isolated browser profile
mkdir ~/timesafari-dev-data
@@ -150,7 +144,6 @@ URL generation across all environments. This prevents localhost URLs from
appearing in shared links during development.
### Key Features
-**Production URLs for Sharing**: All copy link buttons use production domain
-**Environment-Specific Internal URLs**: Internal operations use appropriate
environment URLs
@@ -234,7 +227,6 @@ npm run test:prerequisites
- **Build failures**: Run `npm run check:dependencies` to diagnose environment issues
**Required Versions**:
- Node.js: 18+ (LTS recommended)
- npm: 8+ (comes with Node.js)
- Platform-specific tools: Android Studio, Xcode (for mobile builds)
@@ -254,26 +246,25 @@ To add a Font Awesome icon, add to `fontawesome.ts` and reference with
### Reference Material
- Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
- [Customize Vue configuration](https://cli.vuejs.org/config/).
* [Customize Vue configuration](https://cli.vuejs.org/config/).
- If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
### Code Organization
The project uses a centralized approach to type definitions and interfaces:
- `src/interfaces/` - Contains all TypeScript interfaces and type definitions
- `deepLinks.ts` - Deep linking type system and Zod validation schemas
- `give.ts` - Give-related interfaces and type definitions
- `claims.ts` - Claim-related interfaces and verifiable credentials
- `common.ts` - Shared interfaces and utility types
- Other domain-specific interface files
* `src/interfaces/` - Contains all TypeScript interfaces and type definitions
* `deepLinks.ts` - Deep linking type system and Zod validation schemas
* `give.ts` - Give-related interfaces and type definitions
* `claims.ts` - Claim-related interfaces and verifiable credentials
* `common.ts` - Shared interfaces and utility types
* Other domain-specific interface files
Key principles:
- All interfaces and types are defined in the interfaces folder
- Zod schemas are used for runtime validation and type generation
- Domain-specific interfaces are separated into their own files
@@ -284,11 +275,11 @@ Key principles:
The application uses a platform-agnostic database layer with Vue mixins for service access:
- `src/services/PlatformService.ts` - Database interface definition
- `src/services/PlatformServiceFactory.ts` - Platform-specific service factory
- `src/services/AbsurdSqlDatabaseService.ts` - SQLite implementation
- `src/utils/PlatformServiceMixin.ts` - Vue mixin for database access with caching
- `src/db/` - Legacy Dexie database (migration in progress)
* `src/services/PlatformService.ts` - Database interface definition
* `src/services/PlatformServiceFactory.ts` - Platform-specific service factory
* `src/services/AbsurdSqlDatabaseService.ts` - SQLite implementation
* `src/utils/PlatformServiceMixin.ts` - Vue mixin for database access with caching
* `src/db/` - Legacy Dexie database (migration in progress)
**Development Guidelines**:
@@ -325,11 +316,11 @@ timesafari/
Gifts make the world go 'round!
- [WebStorm by JetBrains](https://www.jetbrains.com/webstorm/) for the free open-source license
- [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
- [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
- [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
- [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
- Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
- [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
- Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)
* [WebStorm by JetBrains](https://www.jetbrains.com/webstorm/) for the free open-source license
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
* [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
* Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
* [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
* Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)

View File

@@ -5,33 +5,33 @@
We can't trust iOS IndexedDB to persist. I want to start delivering an app to people now, in preparation for presentations mid-June: Rotary on June 12 and Porcfest on June 17.
- Apple WebKit puts a [7-day cap on IndexedDB](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/).
* Apple WebKit puts a [7-day cap on IndexedDB](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/).
- The web standards expose a `persist` method to mark memory as persistent, and [supposedly WebView supports it](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persisted), but too many other things indicate it's not reliable. I've talked with [ChatGPT](https://chatgpt.com/share/68322f40-84c8-8007-b213-855f7962989a) & Venice & Claude (in Cursor); [this answer from Perplexity](https://www.perplexity.ai/search/which-platforms-prompt-the-use-HUQLqy4qQD2cRbkmO4CgHg) says that most platforms don't prompt and Safari doesn't support it; I don't know if that means WebKit as well.
* The web standards expose a `persist` method to mark memory as persistent, and [supposedly WebView supports it](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persisted), but too many other things indicate it's not reliable. I've talked with [ChatGPT](https://chatgpt.com/share/68322f40-84c8-8007-b213-855f7962989a) & Venice & Claude (in Cursor); [this answer from Perplexity](https://www.perplexity.ai/search/which-platforms-prompt-the-use-HUQLqy4qQD2cRbkmO4CgHg) says that most platforms don't prompt and Safari doesn't support it; I don't know if that means WebKit as well.
- Capacitor says [not to trust it on iOS](https://capacitorjs.com/docs/v6/guides/storage).
* Capacitor says [not to trust it on iOS](https://capacitorjs.com/docs/v6/guides/storage).
Also, with sensitive data, the accounts info should be encrypted.
# Options
- There is a community [SQLite plugin for Capacitor](https://github.com/capacitor-community/sqlite) with encryption by [SQLCipher](https://github.com/sqlcipher/sqlcipher).
* There is a community [SQLite plugin for Capacitor](https://github.com/capacitor-community/sqlite) with encryption by [SQLCipher](https://github.com/sqlcipher/sqlcipher).
- [This tutorial](https://jepiqueau.github.io/2023/09/05/Ionic7Vue-SQLite-CRUD-App.html#part-1---web---table-of-contents) shows how that plugin works for web as well as native.
* [This tutorial](https://jepiqueau.github.io/2023/09/05/Ionic7Vue-SQLite-CRUD-App.html#part-1---web---table-of-contents) shows how that plugin works for web as well as native.
- Capacitor abstracts [user preferences in an API](https://capacitorjs.com/docs/apis/preferences), which uses different underlying libraries on iOS & Android. Unfortunately, it won't do any filtering or searching, and is only meant for small amounts of data. (It could be used for settings and for identifiers, but contacts will grow and image blobs won't work.)
* Capacitor abstracts [user preferences in an API](https://capacitorjs.com/docs/apis/preferences), which uses different underlying libraries on iOS & Android. Unfortunately, it won't do any filtering or searching, and is only meant for small amounts of data. (It could be used for settings and for identifiers, but contacts will grow and image blobs won't work.)
- There are hints that Capacitor offers another custom storage API but all I could find was that Preferences API.
* There are hints that Capacitor offers another custom storage API but all I could find was that Preferences API.
- [Ionic Storage](https://ionic.io/docs/secure-storage) is an enterprise solution, which also supports encryption.
* [Ionic Storage](https://ionic.io/docs/secure-storage) is an enterprise solution, which also supports encryption.
- Not an option yet: Dexie may support SQLite in [a future version](https://dexie.org/roadmap/dexie5.0).
* Not an option yet: Dexie may support SQLite in [a future version](https://dexie.org/roadmap/dexie5.0).
# Current Plan
- Implement SQLite for Capacitor & web, with encryption. That will allow us to test quickly and keep the same interface for native & web, but we don't deal with migrations for current web users.
* Implement SQLite for Capacitor & web, with encryption. That will allow us to test quickly and keep the same interface for native & web, but we don't deal with migrations for current web users.
- After that is delivered, write a migration for current web users from IndexedDB to SQLite.
* After that is delivered, write a migration for current web users from IndexedDB to SQLite.
# Current method calls

View File

@@ -1,21 +0,0 @@
# Glossary
**T (slot time)** — The local wall-clock time a notification should fire (e.g., 08:00).
**Tlead** — The moment **`prefetchLeadMinutes`** before **T** when the system *attempts* a **single** background prefetch. Tlead **controls prefetch attempts, not arming**; locals are pre-armed earlier to guarantee closed-app delivery.
**Rolling window** — Always keep **today's remaining** (and tomorrow if iOS pending caps allow) locals **armed** so the OS can deliver while the app is closed.
**TTL (time-to-live)** — Maximum allowed payload age **at fire time**. If `T fetchedAt > ttlSeconds`, we **skip** arming for that T.
**Shared DB (default)** — The app and plugin open the **same SQLite file**; the app owns schema/migrations, the plugin performs short writes with WAL.
**WAL (Write-Ahead Logging)** — SQLite journaling mode that permits concurrent reads during writes; recommended for foreground-read + background-write.
**`PRAGMA user_version`** — An integer the app increments on each migration; the plugin **checks** (does not migrate) to ensure compatibility.
**Exact alarm (Android)** — Minute-precise alarm via `AlarmManager.setExactAndAllowWhileIdle`, subject to policy and permission.
**Windowed alarm (Android)** — Batched/inexact alarm via `setWindow(start,len)`; we target **±10 minutes** when exact alarms are unavailable.
**Start-on-Login** — Electron feature that automatically launches the application when the user logs into their system, enabling background notification scheduling and delivery after system reboot.

View File

@@ -330,7 +330,6 @@ Track the effectiveness of your Build Architecture Guard:
## 📝 **Changelog**
### 2025-08-22 - Shell Compatibility Fix
- **Fixed**: Replaced `mapfile` command with portable alternative for cross-shell compatibility
- **Impact**: Resolves "mapfile: command not found" errors in pre-commit hooks
- **Files**: `scripts/build-arch-guard.sh`

View File

@@ -1,11 +1,77 @@
# TimeSafari — Native-First Notification System (Clean Pack) — 2025-09-07
# TimeSafari Docs
This pack contains a single-version **Native-First** documentation set with a clear definition of **Tlead** and aligned terminology.
## Generating PDF from Markdown on OSx
**Native-First =** OS-scheduled **background prefetch at Tlead** + **pre-armed one-shot local notifications**. Web-push is retired.
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew.
**Included files**
### Set Up
- `notification-system.md` (merged comprehensive guide)
- `web-push-cleanup-guide.md` (cleanup instructions)
- `GLOSSARY.md` (definitions incl. **T** and **Tlead**)
```bash
brew install pandoc
brew install basictex
# Setting up LaTex packages
# First update tlmgr
sudo tlmgr update --self
# Then install LaTex packages
sudo tlmgr install bbding
sudo tlmgr install enumitem
sudo tlmgr install environ
sudo tlmgr install fancyhdr
sudo tlmgr install framed
sudo tlmgr install import
sudo tlmgr install lastpage # Enables Page X of Y
sudo tlmgr install mdframed
sudo tlmgr install multirow
sudo tlmgr install needspace
sudo tlmgr install ntheorem
sudo tlmgr install tabu
sudo tlmgr install tcolorbox
sudo tlmgr install textpos
sudo tlmgr install titlesec
sudo tlmgr install titling # Required for the fancy headers used
sudo tlmgr install threeparttable
sudo tlmgr install trimspaces
sudo tlmgr install tocloft # Required for \tableofcontents generation
sudo tlmgr install varwidth
sudo tlmgr install wrapfig
# Install fonts
sudo tlmgr install cmbright
sudo tlmgr install collection-fontsrecommended # And set up fonts
sudo tlmgr install fira
sudo tlmgr install fontaxes
sudo tlmgr install libertine # The main font the doc uses
sudo tlmgr install opensans
sudo tlmgr install sourceserifpro
```
#### References
The following guide was adapted to this project except that we install with Brew and have a few more packages.
Guide: <https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x>
### Usage
Use the `pandoc` command to generate a PDF.
```bash
pandoc usage-guide.md -o usage-guide.pdf
```
And you can open the PDF with the `open` command.
```bash
open usage-guide.pdf
```
Or use this one-liner
```bash
pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf
```

View File

@@ -117,25 +117,25 @@ async function getDatabaseService() {
## Files Modified
1. **src/interfaces/worker-messages.ts** _(NEW)_
1. **src/interfaces/worker-messages.ts** *(NEW)*
- Type definitions for worker communication
- Request and response message interfaces
2. **src/registerSQLWorker.js** _(MAJOR REWRITE)_
2. **src/registerSQLWorker.js** *(MAJOR REWRITE)*
- Message-based operation handling
- **Fixed circular dependency with lazy loading**
- Proper error handling and response formatting
3. **src/services/platforms/WebPlatformService.ts** _(MAJOR REWRITE)_
3. **src/services/platforms/WebPlatformService.ts** *(MAJOR REWRITE)*
- Worker-only database access
- Message sending and response handling
- Timeout and error management
4. **src/main.web.ts** _(SIMPLIFIED)_
4. **src/main.web.ts** *(SIMPLIFIED)*
- Removed duplicate worker creation
- Simplified initialization flow
5. **WORKER_ONLY_DATABASE_IMPLEMENTATION.md** _(NEW)_
5. **WORKER_ONLY_DATABASE_IMPLEMENTATION.md** *(NEW)*
- Complete documentation of changes
## Benefits

View File

@@ -11,14 +11,12 @@ The Android Asset Validation System automatically detects and fixes missing Andr
## Problem Solved
Previously, Android builds would fail with errors like:
```
error: resource drawable/splash (aka app.timesafari.app:drawable/splash) not found.
error: resource mipmap/ic_launcher (aka app.timesafari.app:mipmap/ic_launcher) not found.
```
This happened when:
- Source assets existed but weren't generated into Android resources
- Android resource directories were missing
- Asset generation tools weren't run before building
@@ -47,19 +45,16 @@ npm run build:android:studio
### What Gets Validated
#### Source Assets (Required)
- `resources/icon.png` - App icon source
- `resources/splash.png` - Splash screen source
- `resources/splash_dark.png` - Dark mode splash source
#### Android Resources (Generated)
- `android/app/src/main/res/drawable/splash.png` - Splash screen drawable
- `android/app/src/main/res/mipmap-*/ic_launcher.png` - App icons for all densities
- `android/app/src/main/res/mipmap-*/ic_launcher_round.png` - Round app icons for all densities
### Density Levels Checked
- `mipmap-mdpi` (1x)
- `mipmap-hdpi` (1.5x)
- `mipmap-xhdpi` (2x)
@@ -69,7 +64,6 @@ npm run build:android:studio
## Usage
### Automatic Validation (Recommended)
The validation runs automatically during all Android builds:
```bash
@@ -84,7 +78,6 @@ npm run build:android:debug
```
### Manual Validation
Run validation only to check/fix assets:
```bash
@@ -96,7 +89,6 @@ npm run assets:validate:android
```
### Validation Only (No Regeneration)
Check configuration without fixing:
```bash
@@ -106,7 +98,6 @@ npm run assets:validate
## Error Handling
### Missing Source Assets
If source assets are missing, the build fails with clear error messages:
```
@@ -117,7 +108,6 @@ If source assets are missing, the build fails with clear error messages:
```
### Missing Generated Resources
If generated resources are missing, they're automatically regenerated:
```
@@ -129,7 +119,6 @@ If generated resources are missing, they're automatically regenerated:
```
### Generation Failure
If regeneration fails, helpful guidance is provided:
```
@@ -142,7 +131,6 @@ If regeneration fails, helpful guidance is provided:
## Integration Points
### Build Script Integration
The validation is integrated into the main build process:
```bash
@@ -155,7 +143,6 @@ validate_android_assets || {
```
### NPM Scripts
New npm scripts for asset management:
```json
@@ -169,20 +156,17 @@ New npm scripts for asset management:
## Benefits
### For Developers
- **No More Build Failures**: Automatic detection and fixing of missing resources
- **Faster Development**: No need to manually run asset generation tools
- **Clear Error Messages**: Helpful guidance when issues occur
- **Consistent Results**: Same validation on all development machines
### For CI/CD
- **Reliable Builds**: Consistent asset validation across environments
- **Early Detection**: Catches issues before they reach production
- **Automated Fixes**: Self-healing builds when possible
### For Project Maintenance
- **Reduced Support**: Fewer "build doesn't work" issues
- **Documentation**: Clear requirements for required assets
- **Standardization**: Consistent asset structure across the project
@@ -192,27 +176,21 @@ New npm scripts for asset management:
### Common Issues
#### "No assets found in the asset path"
This occurs when the `assets/` directory is empty. The validation system automatically copies source assets and regenerates them.
#### "Failed to generate Android assets"
Check that:
- Source assets exist in `resources/`
- `@capacitor/assets` is installed
- You have write permissions to the Android directories
#### "Asset generation completed but some resources are still missing"
This indicates a problem with the asset generation tool. Try:
1. Running `npm install` to ensure dependencies are up to date
2. Manually running `npx @capacitor/assets generate`
3. Checking the asset generation logs for specific errors
### Manual Recovery
If automatic regeneration fails, you can manually create the missing resources:
```bash
@@ -235,14 +213,12 @@ rm assets/icon.png assets/splash.png assets/splash_dark.png
## Future Enhancements
### Planned Improvements
- **iOS Asset Validation**: Extend validation to iOS assets
- **Asset Quality Checks**: Validate image dimensions and formats
- **Performance Optimization**: Cache validation results
- **CI/CD Integration**: Add validation to GitHub Actions
### Configuration Options
- **Custom Asset Paths**: Support for different asset directory structures
- **Validation Rules**: Configurable validation requirements
- **Skip Options**: Ability to skip validation for specific scenarios

View File

@@ -122,4 +122,4 @@ export default class HomeView extends Vue {
---
_This decision was made based on the current codebase architecture and team expertise. The mixin approach provides the best balance of performance, developer experience, and architectural consistency for the TimeSafari application._
*This decision was made based on the current codebase architecture and team expertise. The mixin approach provides the best balance of performance, developer experience, and architectural consistency for the TimeSafari application.*

View File

@@ -92,5 +92,5 @@ Multiple stack traces showing Vue router navigation and component mounting cycle
3. Address API/server issues in separate debugging session
---
_Log Entry by: Migration Assistant_
_Session: ProjectsView.vue Triple Migration Pattern_
*Log Entry by: Migration Assistant*
*Session: ProjectsView.vue Triple Migration Pattern*

View File

@@ -32,7 +32,6 @@ you apply 1-3 meta-rules that automatically include everything you need.
### **Step 1: Always Start with Core Always-On**
**Every single interaction** starts with:
```
meta_core_always_on.mdc
```
@@ -66,14 +65,12 @@ meta_core_always_on + meta_research + meta_bug_diagnosis
**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**:
```
@@ -83,13 +80,11 @@ 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)
@@ -108,19 +103,16 @@ Planning Mode → Invoke @meta_feature_implementation → Implementation Mode
**Scenario**: User reports that the contact list isn't loading properly
**Initial Meta-Rule Selection**:
```
meta_core_always_on + meta_research + meta_bug_diagnosis
```
**What This Gives You**:
- **Core Always-On**: Human competence focus, time standards, context
- **Research**: Systematic investigation methodology, evidence collection
- **Bug Diagnosis**: Defect analysis framework, root cause identification
**Flexible Workflow**:
1. Apply core always-on for foundation
2. Use research meta-rule for systematic investigation
3. Switch to bug diagnosis when you have enough evidence
@@ -133,19 +125,16 @@ meta_core_always_on + meta_research + meta_bug_diagnosis
**Scenario**: Building a new contact search feature
**Meta-Rule Selection**:
```
meta_core_always_on + meta_feature_planning + meta_feature_implementation
```
**What This Gives You**:
- **Core Always-On**: Foundation principles and context
- **Feature Planning**: Requirements analysis, architecture planning
- **Feature Implementation**: Development workflow, testing strategy
**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
@@ -158,18 +147,15 @@ meta_core_always_on + meta_feature_planning + meta_feature_implementation
**Scenario**: Writing a migration guide for the new database system
**Meta-Rule Selection**:
```
meta_core_always_on + meta_documentation
```
**What This Gives You**:
- **Core Always-On**: Foundation and context
- **Documentation**: Educational focus, templates, quality standards
**Parallel Workflow**:
1. Apply core always-on for foundation
2. Use documentation meta-rule for educational content creation
3. **Can research** while documenting if you need more information
@@ -212,35 +198,27 @@ Each meta-rule includes success criteria. Use these to validate your work:
## Common Meta-Rule Combinations
### **Research + Diagnosis**
```
meta_core_always_on + meta_research + meta_bug_diagnosis
```
**Use for**: Complex bug investigations requiring systematic analysis
### **Planning + Implementation**
```
meta_core_always_on + meta_feature_planning + meta_feature_implementation
```
**Use for**: End-to-end feature development from concept to deployment
### **Research + Planning**
```
meta_core_always_on + meta_research + meta_feature_planning
```
**Use for**: Feasibility research and solution design
### **Documentation + Context**
```
meta_core_always_on + meta_documentation + [context-specific]
```
**Use for**: Creating comprehensive, educational documentation
## Best Practices

View File

@@ -1,231 +0,0 @@
# TimeSafari — Native-First Notification System
**Status:** Ready for implementation
**Date:** 2025-09-07
**Author:** Matthew Raymer
---
## Executive Summary
Ship a **single, Native-First** notification system: OS-scheduled **background prefetch at Tlead** + **pre-armed** local notifications. Web-push is retired.
### What we deliver
- **Closed-app delivery:** Pre-armed locals fire even if the app is closed.
- **Freshness:** One prefetch attempt per slot at **Tlead**; ETag/TTL controls; skip when stale.
- **Android precision:** Exact alarms with permission; windowed fallback (±10m) otherwise.
- **Resilience:** Re-arm after reboot/time-change (Android receivers; iOS on next wake/silent push).
- **Cross-platform:** Same TS API (iOS/Android/Electron). Electron is best-effort while running.
### Success signals
- High delivery reliability, minute-precision on Android with permission.
- Prefetch budget hit rate at **Tlead**; zero stale deliveries beyond TTL.
---
## Strategic Plan
### Goal
Deliver 1..M daily notifications with **OS background prefetch at Tlead** and **rolling-window safety** so messages display with fresh content even when the app is closed.
### Tenets
- **Reliability first:** OS delivers once scheduled; no JS at delivery time.
- **Freshness with guardrails:** Prefetch at **Tlead**; enforce **TTL-at-fire**; ETag-aware.
- **Single system:** One TS API; native adapters swap under the hood.
- **Platform honesty:** Android exactness via permission; iOS best-effort budget.
### Architecture (high level)
App (Vue/TS) → Orchestrator (policy) → Native Adapters:
- **SchedulerNative** — AlarmManager (Android) / UNUserNotificationCenter (iOS)
- **BackgroundPrefetchNative** — WorkManager (Android) / BGTaskScheduler (+ silent push) (iOS)
- **DataStore** — SQLite
**Storage (single shared DB):** The app and the native plugin will use **the same SQLite database file**. The app owns schema/migrations; the plugin opens the same file with WAL enabled and performs short, serialized writes. This keeps one source of truth for payloads, delivery logs, and config.
### SQLite Ownership & Concurrency
* **One DB file:** The plugin opens the **same path** the app uses (no second DB).
* **Migrations owned by app:** The app executes schema migrations and bumps `PRAGMA user_version`. The plugin **never** migrates; it **asserts** the expected version.
* **WAL mode:** Open DB with `journal_mode=WAL`, `synchronous=NORMAL`, `busy_timeout=5000`, `foreign_keys=ON`. WAL allows foreground reads while a background job commits quickly.
* **Single-writer discipline:** Background jobs write in **short transactions** (UPSERT per slot), then return.
* **Encryption (optional):** If using SQLCipher, the **same key** is used by both app and plugin. Do not mix encrypted and unencrypted openings.
### Scheduling & Tlead
- **Arm** a rolling window (today + tomorrow within iOS cap).
- **Attempt** a single **online-first** fetch per slot at **Tlead = T prefetchLeadMinutes**.
- If prefetch is skipped, the armed local **still fires** using cached content.
### Policies
- **TTL-at-fire:** If (T fetchedAt) > `ttlSeconds`**skip** arming.
- **Android exactness:** Request `SCHEDULE_EXACT_ALARM`; fallback **±10m** window.
- **Reboot/time change:** Android receivers re-arm next 24h; iOS on next wake/silent push.
- **No delivery-time mutation:** iOS locals cannot be mutated by NSE; render before scheduling.
---
## Implementation Guide
### 1) Interfaces (TS stable)
- **SchedulerNative**: `scheduleExact({slotId, whenMs, title, body, extra})`, `scheduleWindow(..., windowLenMs)`, `cancelBySlot`, `rescheduleAll`, `capabilities()`
- **BackgroundPrefetchNative**: `schedulePrefetch(slotId, atMs)`, `cancelPrefetch(slotId)`
- **DataStore**: SQLite adapters (notif_contents, notif_deliveries, notif_config)
- **Public API**: `configure`, `requestPermissions`, `runFullPipelineNow`, `reschedule`, `getState`
### DB Path & Adapter Configuration
* **Configure option:** `dbPath: string` (absolute path or platform alias) is passed from JS to the plugin during `configure()`.
* **Shared tables:**
* `notif_contents(slot_id, payload_json, fetched_at, etag, …)`
* `notif_deliveries(slot_id, fire_at, delivered_at, status, error_code, …)`
* `notif_config(k, v)`
* **Open settings:**
* `journal_mode=WAL`
* `synchronous=NORMAL`
* `busy_timeout=5000`
* `foreign_keys=ON`
**Type (TS) extension**
```ts
export type ConfigureOptions = {
// …existing fields…
dbPath: string; // shared DB file the plugin will open
storage: 'shared'; // canonical value; plugin-owned DB is not used
};
```
**Plugin side (pseudo)**
```kotlin
// Android open
val db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READWRITE)
db.execSQL("PRAGMA journal_mode=WAL")
db.execSQL("PRAGMA synchronous=NORMAL")
db.execSQL("PRAGMA foreign_keys=ON")
db.execSQL("PRAGMA busy_timeout=5000")
// Verify schema version
val uv = rawQuery("PRAGMA user_version").use { it.moveToFirst(); it.getInt(0) }
require(uv >= MIN_EXPECTED_VERSION) { "Schema version too old" }
```
```swift
// iOS open (FMDB / SQLite3)
// Set WAL via PRAGMA after open; check user_version the same way.
```
### 2) Templating & Arming
- Render `title/body` **before** scheduling; pass via **SchedulerNative**.
- Route all arming through **SchedulerNative** to centralize Android exact/window semantics.
### 3) Tlead (single attempt)
**Tlead governs prefetch, not arming.** We **arm** one-shot locals as part of the rolling window so closed-app delivery is guaranteed. At **Tlead = T prefetchLeadMinutes**, the **native background job** attempts **one** 12s ETag-aware fetch. If fresh content arrives and will not violate **TTL-at-fire**, we (re)arm the upcoming slot; if the OS skips the wake, the pre-armed local still fires with cached content.
- Compute Tlead = `whenMs - prefetchLeadMinutes*60_000`.
- `BackgroundPrefetchNative.schedulePrefetch(slotId, atMs=Tlead)`.
- On wake: **ETag** fetch (timeout **12s**), persist, optionally cancel & re-arm if within TTL.
- Never fetch at delivery time.
### 4) TTL-at-fire
**TTL-at-fire:** Before arming for time **T**, compute `T fetchedAt`. If that exceeds `ttlSeconds`, **do not arm** (skip). This prevents posting stale notifications when the app has been closed for a long time.
`if (whenMs - fetchedAt) > ttlSeconds*1000 → skip`
### 5) Android specifics
- Request `SCHEDULE_EXACT_ALARM`; deep-link if denied; fallback to `setWindow(start,len)` (±10m).
- Receivers: `BOOT_COMPLETED`, `TIMEZONE_CHANGED`, `TIME_SET` → recompute & re-arm for next 24h and schedule Tlead prefetch.
### 6) iOS specifics
- `BGTaskScheduler` for Tlead prefetch (best-effort). Optional silent push nudge.
- Locals: `UNCalendarNotificationTrigger` (one-shots); no NSE mutation for locals.
### 7) Network & Timeouts
- Content fetch: **12s** timeout; single attempt at Tlead; ETag/304 respected.
- ACK/Error: **8s** timeout, fire-and-forget.
### 8) Electron
- Notifications while app is running; recommend **Start-on-Login**. No true background scheduling when fully closed.
### 9) Telemetry
- Record `scheduled|shown|error`; ACK deliveries (8s timeout); include slot/times/TZ/app version.
---
## Capability Matrix
| Capability | Android (Native) | iOS (Native) | Electron | Web |
|---|---|---|---|---|
| Multi-daily locals (closed app) | ✅ | ✅ | ✅ (app running) | — |
| Prefetch at Tlead (app closed) | ✅ WorkManager | ⚠️ BGTask (best-effort) | ✅ (app running) | — |
| Re-arm after reboot/time-change | ✅ Receivers | ⚠️ On next wake/silent push | ✅ Start-on-Login | — |
| Minute-precision alarms | ✅ with exact permission | ❌ not guaranteed | ✅ timer best-effort | — |
| Delivery-time mutation for locals | ❌ | ❌ | — | — |
| ETag/TTL enforcement | ✅ | ✅ | ✅ | — |
| Rolling-window safety | ✅ | ✅ | ✅ | — |
---
## Acceptance Criteria
### Core
- **Closed-app delivery:** Armed locals fire at T with last rendered content. No delivery-time network.
- **Tlead prefetch:** Single background attempt at **Tlead**; if skipped, delivery still occurs from cache.
- **TTL-at-fire:** No armed local violates TTL at T.
### Android
- **Exact permission path:** With `SCHEDULE_EXACT_ALARM` → within ±1m; else **±10m** window.
- **Reboot recovery:** After reboot, receivers re-arm next 24h and schedule Tlead prefetch.
- **TZ/DST change:** Recompute & re-arm; future slots align to new wall-clock.
### iOS
- **BGTask budget respected:** Prefetch often runs but may be skipped; delivery still occurs via rolling window.
- **Force-quit caveat:** No background execution after user terminate; delivery still occurs if pre-armed.
### Electron
- **Running-app rule:** Delivery only while app runs; with Start-on-Login, after reboot the orchestrator re-arms and subsequent slots deliver.
### Network
- Content fetch timeout **12s**; ACK/Error **8s**; no retries inside lead; ETag honored.
### Observability
- Log/telemetry for `scheduled|shown|error`; ACK payload includes slot, times, device TZ, app version.
### DB Sharing
* **Shared DB visibility:** A background prefetch writes `notif_contents`; the foreground UI **immediately** reads the same row.
* **WAL overlap:** With the app reading while the plugin commits, no user-visible blocking occurs.
* **Version safety:** If `user_version` is behind, the plugin emits an error and does not write (protects against partial installs).
---
## Web-Push Cleanup
Web-push functionality has been retired due to unreliability. All web-push related code paths and documentation sections should be removed or marked as deprecated. See `web-push-cleanup-guide.md` for detailed cleanup steps.
---
_This document consolidates the Native-First notification system strategy, implementation details, capabilities, and acceptance criteria into a single comprehensive reference._

View File

@@ -1,551 +0,0 @@
# TimeSafari Web-Push Cleanup Guide
**Status:** 🚀 Native-First Implementation
**Date:** 2025-01-27T14:30Z (UTC)
**Author:** Matthew Raymer
**Scope:** Web-push code cleanup and deprecation
**Goal:** Remove or quarantine all web-push code paths and mark as deprecated.
---
## Executive Summary
This document provides a comprehensive cleanup guide for removing web-push code
paths from TimeSafari. Web-push has been retired for unreliability, and the
system now focuses on native mobile reliability with Electron best-effort support.
---
## Cleanup Strategy
### Phase 1: Identify Web-Push Code Paths
#### Service Worker Files
- [ ] `sw_scripts/notification-click.js` - Mark as deprecated
- [ ] `sw_scripts/` directory - Review for web-push dependencies
- [ ] Service worker registration code - Remove or quarantine
#### Web-Specific Code
- [ ] Web push notification handlers
- [ ] Service worker event listeners
- [ ] Web notification API usage
- [ ] Push subscription management
#### Configuration Files
- [ ] VitePWA plugin configuration
- [ ] Service worker build configuration
- [ ] Web push manifest files
### Phase 2: Mark as Deprecated
#### Code Comments
```javascript
// DEPRECATED: Web-push notification handling
// This code is kept for reference but not used in production
// Replaced by Native-First notification system
```
#### Documentation Updates
- [ ] Mark web-push sections as deprecated
- [ ] Add deprecation notices
- [ ] Update README files
- [ ] Update API documentation
### Phase 3: Remove or Quarantine
#### Complete Removal
- [ ] Web push subscription code
- [ ] Service worker notification handlers
- [ ] Web-specific notification APIs
- [ ] Push message handling
#### Quarantine (Keep for Reference)
- [ ] Service worker registration code
- [ ] Web push configuration
- [ ] Historical web-push tests
---
## Detailed Cleanup Tasks
### 1. Service Worker Cleanup
#### Files to Deprecate
**`sw_scripts/notification-click.js`**
```javascript
// DEPRECATED: Service worker notification handling
// This code is kept for reference but not used in production
// Replaced by Native-First notification system
// Original web-push notification click handler
self.addEventListener('notificationclick', (event) => {
// DEPRECATED: Web-push only
event.notification.close();
const slotId = event.notification.data?.slotId;
const route = slotId ? '/#/daily' : '/#/notifications';
event.waitUntil(
clients.openWindow(route).catch(() => {
return clients.openWindow('/');
})
);
});
```
**Service Worker Registration**
```javascript
// DEPRECATED: Service worker registration
// This code is kept for reference but not used in production
// Replaced by Native-First notification system
if ('serviceWorker' in navigator && process.env.VITE_PLATFORM === 'web') {
// DEPRECATED: Web-push only
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered:', registration);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
```
### 2. Web Push API Cleanup
#### Push Subscription Management
```javascript
// DEPRECATED: Web push subscription management
// This code is kept for reference but not used in production
// Replaced by Native-First notification system
class WebPushManager {
// DEPRECATED: Web-push only
async subscribeToPush() {
// Implementation kept for reference
}
// DEPRECATED: Web-push only
async unsubscribeFromPush() {
// Implementation kept for reference
}
}
```
#### Push Message Handling
```javascript
// DEPRECATED: Push message handling
// This code is kept for reference but not used in production
// Replaced by Native-First notification system
self.addEventListener('push', (event) => {
// DEPRECATED: Web-push only
const data = event.data ? event.data.json() : {};
const options = {
body: data.body,
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
data: data
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
```
### 3. Configuration Cleanup
#### VitePWA Plugin Configuration
```javascript
// DEPRECATED: VitePWA plugin configuration
// This configuration is kept for reference but not used in production
// Replaced by Native-First notification system
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
// DEPRECATED: Web-push only
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}']
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
manifest: {
name: 'TimeSafari',
short_name: 'TimeSafari',
description: 'TimeSafari App',
theme_color: '#ffffff',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
})
]
})
```
#### Service Worker Build Configuration
```javascript
// DEPRECATED: Service worker build configuration
// This configuration is kept for reference but not used in production
// Replaced by Native-First notification system
export default defineConfig({
build: {
rollupOptions: {
input: {
// DEPRECATED: Web-push only
sw: 'sw_scripts/notification-click.js'
}
}
}
})
```
### 4. Test Cleanup
#### Web Push Tests
```javascript
// DEPRECATED: Web push tests
// These tests are kept for reference but not used in production
// Replaced by Native-First notification system
describe('Web Push Notifications (DEPRECATED)', () => {
// DEPRECATED: Web-push only
it('should handle push notifications', async () => {
// Test implementation kept for reference
});
// DEPRECATED: Web-push only
it('should handle notification clicks', async () => {
// Test implementation kept for reference
});
});
```
#### Service Worker Tests
```javascript
// DEPRECATED: Service worker tests
// These tests are kept for reference but not used in production
// Replaced by Native-First notification system
describe('Service Worker (DEPRECATED)', () => {
// DEPRECATED: Web-push only
it('should register service worker', async () => {
// Test implementation kept for reference
});
// DEPRECATED: Web-push only
it('should handle push events', async () => {
// Test implementation kept for reference
});
});
```
### 5. Documentation Cleanup
#### README Updates
```markdown
# TimeSafari Native-First Notification System
## Web-Push Status: DEPRECATED
Web-push has been retired for unreliability. The system now focuses on native mobile reliability with Electron best-effort support.
### Deprecated Features
- ❌ Web push notifications
- ❌ Service worker notification handling
- ❌ Web notification API
### Active Features
- ✅ Native mobile notifications (Android/iOS)
- ✅ Electron notifications (best-effort)
- ✅ OS-scheduled background prefetch
- ✅ Rolling window safety
```
#### API Documentation Updates
```markdown
## Notification API (Native-First)
### Deprecated Methods
- `subscribeToPush()` - DEPRECATED: Web-push only
- `unsubscribeFromPush()` - DEPRECATED: Web-push only
- `handlePushMessage()` - DEPRECATED: Web-push only
### Active Methods
- `scheduleExact()` - Native exact scheduling
- `scheduleWindow()` - Native windowed scheduling
- `schedulePrefetch()` - Native background prefetch
```
---
## File-by-File Cleanup Checklist
### Service Worker Files
- [ ] `sw_scripts/notification-click.js` - Mark as deprecated
- [ ] `sw_scripts/` directory - Review for web-push dependencies
- [ ] Service worker build configuration - Remove or quarantine
### Web-Specific Code
- [ ] `src/main.web.ts` - Remove service worker registration
- [ ] `src/services/webPush.ts` - Mark as deprecated
- [ ] `src/utils/serviceWorker.ts` - Mark as deprecated
- [ ] Web notification API usage - Remove or quarantine
### Configuration Files
- [ ] `vite.config.web.mts` - Remove VitePWA plugin
- [ ] `package.json` - Remove web-push dependencies
- [ ] `public/manifest.json` - Mark as deprecated
- [ ] Service worker build scripts - Remove or quarantine
### Test Files
- [ ] `test-playwright/web-push.spec.ts` - Mark as deprecated
- [ ] `test/services/webPush.test.ts` - Mark as deprecated
- [ ] Service worker tests - Mark as deprecated
### Documentation Files
- [ ] `README.md` - Update to reflect native-first approach
- [ ] `doc/web-push.md` - Mark as deprecated
- [ ] API documentation - Remove web-push references
---
## Dependencies to Remove
### NPM Packages
```json
{
"dependencies": {
// DEPRECATED: Web-push only
"web-push": "^7.4.0",
"vite-plugin-pwa": "^0.17.0"
},
"devDependencies": {
// DEPRECATED: Web-push only
"workbox-webpack-plugin": "^6.5.0"
}
}
```
### Build Scripts
```json
{
"scripts": {
// DEPRECATED: Web-push only
"build:sw": "workbox generateSW",
"test:sw": "jest --testPathPattern=serviceWorker"
}
}
```
---
## Migration Guide
### From Web-Push to Native-First
#### Step 1: Remove Web-Push Dependencies
```bash
# Remove web-push packages
npm uninstall web-push vite-plugin-pwa workbox-webpack-plugin
# Remove service worker files
rm -rf sw_scripts/
rm -f public/sw.js
rm -f public/workbox-*.js
```
#### Step 2: Update Configuration
```javascript
// Remove VitePWA plugin from vite.config.web.mts
export default defineConfig({
plugins: [
// Remove VitePWA plugin
// VitePWA({ ... })
]
})
```
#### Step 3: Update Service Registration
```javascript
// Remove service worker registration from main.web.ts
// if ('serviceWorker' in navigator) {
// navigator.serviceWorker.register('/sw.js')
// }
```
#### Step 4: Update Tests
```javascript
// Remove web-push tests
// describe('Web Push Notifications', () => { ... })
```
---
## Verification Checklist
### Code Removal Verification
- [ ] No web-push imports remain
- [ ] No service worker registration code
- [ ] No push subscription management
- [ ] No web notification API usage
- [ ] No VitePWA plugin configuration
### Documentation Verification
- [ ] All web-push references marked as deprecated
- [ ] README updated to reflect native-first approach
- [ ] API documentation updated
- [ ] Test documentation updated
### Build Verification
- [ ] Web build succeeds without service worker
- [ ] No service worker files generated
- [ ] No web-push dependencies in bundle
- [ ] Native builds work correctly
### Test Verification
- [ ] Web-push tests are marked as deprecated
- [ ] Native notification tests pass
- [ ] No web-push test failures
- [ ] Test suite runs successfully
---
## Rollback Plan
### Emergency Rollback
If native-first implementation fails, web-push code can be restored:
#### 1. **Restore Dependencies**
```bash
npm install web-push vite-plugin-pwa workbox-webpack-plugin
```
#### 2. **Restore Service Worker Files**
```bash
git checkout HEAD~1 -- sw_scripts/
git checkout HEAD~1 -- public/sw.js
```
#### 3. **Restore Configuration**
```bash
git checkout HEAD~1 -- vite.config.web.mts
git checkout HEAD~1 -- package.json
```
#### 4. **Restore Tests**
```bash
git checkout HEAD~1 -- test-playwright/web-push.spec.ts
git checkout HEAD~1 -- test/services/webPush.test.ts
```
### Rollback Verification
- [ ] Web-push functionality restored
- [ ] Service worker registration works
- [ ] Push notifications work
- [ ] Tests pass
---
## Post-Cleanup Tasks
### Code Review
- [ ] Review all changes for completeness
- [ ] Verify no web-push code remains
- [ ] Check for orphaned references
- [ ] Validate native-first implementation
### Testing
- [ ] Run full test suite
- [ ] Verify native notifications work
- [ ] Check Electron functionality
- [ ] Validate mobile builds
### Documentation
- [ ] Update all documentation
- [ ] Remove web-push references
- [ ] Update API documentation
- [ ] Update user guides
---
## Success Criteria
### Complete Web-Push Removal
- [ ] All web-push code marked as deprecated
- [ ] Service worker files quarantined
- [ ] Dependencies removed
- [ ] Configuration updated
### Native-First Implementation
- [ ] Native notifications work on Android
- [ ] Native notifications work on iOS
- [ ] Electron notifications work
- [ ] Background prefetch works
### Documentation Updated
- [ ] All docs reflect native-first approach
- [ ] Web-push marked as deprecated
- [ ] Migration guide provided
- [ ] Rollback plan documented
---
_This cleanup guide provides comprehensive instructions for removing web-push
code paths from TimeSafari. Web-push has been retired for unreliability, and the
system now focuses on native mobile reliability with Electron best-effort support._

View File

@@ -5,15 +5,12 @@
**Status**: 🎯 **ACTIVE** - Z-index layering standards
## Objective
Establish consistent z-index values across the TimeSafari application to ensure proper layering of UI elements.
## Result
This document defines the z-index hierarchy for all UI components.
## Use/Run
Reference these values when implementing new components or modifying existing ones to maintain consistent layering.
## Z-Index Hierarchy

View File

@@ -1,116 +0,0 @@
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;

View File

@@ -56,6 +56,7 @@
"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"
},

View File

@@ -50,7 +50,6 @@ 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' },
];

View File

@@ -53,7 +53,6 @@ export class ElectronCapacitorApp {
];
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
];
private mainWindowState;

View File

@@ -1,6 +1,6 @@
{
"compileOnSave": true,
"include": ["./src/**/*"],
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
"compilerOptions": {
"outDir": "./build",
"importHelpers": true,

91
package-lock.json generated
View File

@@ -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",
@@ -90,6 +91,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 +108,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",
@@ -6786,6 +6789,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",
@@ -10147,6 +10161,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",
@@ -10154,6 +10174,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",
@@ -32883,6 +32919,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",

View File

@@ -156,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",
@@ -219,6 +220,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",
@@ -235,6 +237,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",

View File

@@ -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/"
# Note: Electron has its own capacitor.config.ts file, so we don't copy the main config
safe_execute "Copying config" "cp capacitor.config.json electron/capacitor.config.json"
}
# Compile TypeScript

View File

@@ -22,4 +22,24 @@
.dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg;
}
/* 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;
}
}

View File

@@ -80,7 +80,10 @@
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="emitLoadClaim(record.jwtId)">
{{ description }}
<vue-markdown
:source="truncatedDescription"
class="markdown-content"
/>
</a>
</p>
@@ -258,11 +261,13 @@ import {
NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications";
import { TIMEOUTS } from "@/utils/notify";
import VueMarkdown from "vue-markdown-render";
@Component({
components: {
EntityIcon,
ProjectIcon,
VueMarkdown,
},
})
export default class ActivityListItem extends Vue {
@@ -303,6 +308,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)}`;
}

View File

@@ -84,8 +84,7 @@ export default class UserNameDialog extends Vue {
*/
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
// Load from account-specific settings instead of master settings
const settings = await this.$accountSettings();
const settings = await this.$settings();
this.givenName = settings.firstName || "";
this.visible = true;
}
@@ -96,18 +95,7 @@ export default class UserNameDialog extends Vue {
*/
async onClickSaveChanges() {
try {
// Get the current active DID to save to user-specific settings
const settings = await this.$accountSettings();
const activeDid = settings.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 });
}
await this.$updateSettings({ firstName: this.givenName });
this.visible = false;
this.callback(this.givenName);
} catch (error) {

View File

@@ -1689,11 +1689,3 @@ 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.",
};

View File

@@ -124,6 +124,13 @@ const MIGRATIONS = [
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
},
{
name: "005_add_starredPlanHandleIds_to_settings",
sql: `
ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
`,
},
];
/**

View File

@@ -157,10 +157,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 +227,11 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
);
}
// Handle searchBoxes parsing
if (settings.searchBoxes) {
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
}
settings.starredPlanHandleIds = parseJsonField(
settings.starredPlanHandleIds,
[],
);
return settings;
} catch (error) {

View File

@@ -36,6 +36,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 +61,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 +89,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;

View File

@@ -72,11 +72,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

View File

@@ -1,4 +1,5 @@
import { GiveActionClaim, OfferClaim } from "./claims";
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
import { GenericCredWrapper } from "./common";
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
@@ -61,6 +62,11 @@ export interface PlanSummaryRecord {
jwtId?: string;
}
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
}
/**
* Represents data about a project
*
@@ -87,7 +93,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;
}

View File

@@ -56,7 +56,12 @@ import {
KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records";
import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app";
@@ -362,6 +367,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
*/
@@ -730,7 +751,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;
@@ -752,7 +773,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;
@@ -766,6 +787,44 @@ 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) {
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
*

View File

@@ -86,6 +86,7 @@ import {
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faStar,
faThumbtack,
faTrashCan,
faTriangleExclamation,
@@ -94,6 +95,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,
@@ -168,14 +172,16 @@ library.add(
faPlus,
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faStar,
faStarRegular,
faThumbtack,
faTrashCan,
faTriangleExclamation,

View File

@@ -614,31 +614,18 @@ export const retrieveAllAccountsMetadata = async (): Promise<
return result;
};
export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account.";
/**
* Saves a new identity to SQL database
* Saves a new identity to both SQL and Dexie databases
*/
export async function saveNewIdentity(
identity: IIdentifier,
mnemonic: string,
derivationPath: string,
): Promise<void> {
try {
// add to the new sql db
const platformService = await getPlatformService();
// Check if account already exists before attempting to save
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[identity.did],
);
if (existingAccount?.values?.length) {
throw new Error(
`Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`,
);
}
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
@@ -672,6 +659,12 @@ export async function saveNewIdentity(
await platformService.updateDefaultSettings({ activeDid: identity.did });
await platformService.insertNewDidIntoSettings(identity.did);
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
}
}
/**
@@ -1039,58 +1032,3 @@ export async function importFromMnemonic(
}
}
}
/**
* 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>;
/**
* 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>;
/**
* Implementation of checkForDuplicateAccount with overloaded signatures
*/
export async function checkForDuplicateAccount(
didOrMnemonic: string,
derivationPath?: string,
): Promise<boolean> {
let didToCheck: string;
if (derivationPath) {
// Derive the DID from mnemonic and derivation path
const [address, privateHex, publicHex] = deriveAddress(
didOrMnemonic.trim().toLowerCase(),
derivationPath,
);
const newId = newIdentifier(address, privateHex, publicHex, derivationPath);
didToCheck = newId.did;
} else {
// Use the provided DID directly
didToCheck = didOrMnemonic;
}
// 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],
);
return (existingAccount?.values?.length ?? 0) > 0;
}

View File

@@ -85,7 +85,6 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({
mixins: [PlatformServiceMixin],
@@ -197,10 +196,10 @@ This tests the helper method only - no database interaction`;
const success = await this.$saveSettings(testSettings);
if (success) {
// Now query the raw database to see how it's actually stored
// Now query the raw database to see how it's actually stored.
// Note that new users probably have settings with ID of 1 but old migrated users might skip to 2.
const rawResult = await this.$dbQuery(
"SELECT searchBoxes FROM settings WHERE id = ?",
[MASTER_SETTINGS_KEY],
"SELECT searchBoxes FROM settings limit 1",
);
if (rawResult?.values?.length) {

View File

@@ -249,13 +249,13 @@ export const PlatformServiceMixin = {
// Keep null values as null
}
// Handle JSON fields like contactMethods
if (column === "contactMethods" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
value = [];
}
// Convert SQLite JSON strings to objects/arrays
if (
column === "contactMethods" ||
column === "searchBoxes" ||
column === "starredPlanHandleIds"
) {
value = this._parseJsonField(value, []);
}
obj[column] = value;

View File

@@ -150,8 +150,6 @@
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<LocationSearchSection :search-box="searchBox" />
<!-- User Profile -->
<section
v-if="isRegistered"
@@ -244,6 +242,8 @@
<div v-else>Saving...</div>
</section>
<LocationSearchSection :search-box="searchBox" />
<UsageLimitsSection
v-if="activeDid"
:loading-limits="loadingLimits"
@@ -1059,8 +1059,8 @@ export default class AccountViewView extends Vue {
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
this.isSearchAreasSet = !!settings.searchBoxes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.isSearchAreasSet =
!!settings.searchBoxes && settings.searchBoxes.length > 0;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingReminder = !!settings.notifyingReminderTime;
@@ -1074,6 +1074,7 @@ export default class AccountViewView extends Vue {
this.passkeyExpirationMinutes =
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer;

View File

@@ -79,7 +79,10 @@
<div class="text-sm">
<div data-testId="description">
<font-awesome icon="message" class="fa-fw text-slate-400" />
{{ claimDescription }}
<vue-markdown
:source="claimDescription"
class="markdown-content"
/>
</div>
<div>
<font-awesome icon="user" class="fa-fw text-slate-400" />
@@ -515,8 +518,10 @@ import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -535,7 +540,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER } from "@/constants/app";
@Component({
components: { GiftedDialog, QuickNav },
components: { GiftedDialog, QuickNav, VueMarkdown },
mixins: [PlatformServiceMixin],
})
export default class ClaimView extends Vue {

View File

@@ -71,22 +71,22 @@
contactFromDid?.seesMe && contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They can see your activity"
title="They can see you"
@click="confirmSetVisibility(contactFromDid, false)"
>
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They cannot see your activity"
title="They cannot see you"
@click="confirmSetVisibility(contactFromDid, true)"
>
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
@@ -95,11 +95,11 @@
contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You watch their activity"
title="I view their content"
@click="confirmViewContent(contactFromDid, false)"
>
<font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-else-if="
@@ -107,11 +107,11 @@
contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You do not watch their activity"
title="I do not view their content"
@click="confirmViewContent(contactFromDid, true)"
>
<font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button

View File

@@ -51,6 +51,33 @@
<!-- Secondary Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li v-if="isProjectsActive">
<a
href="#"
:class="computedStarredTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
isStarredActive = true;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = false;
isSearchVisible = false;
tempSearchBox = null;
searchStarred();
"
>
Starred
<!-- restore when the links don't jump around for different numbers
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
v-if="isLocalActive"
>
{{ localCount > -1 ? localCount : "?" }}
</span>
-->
</a>
</li>
<li>
<a
href="#"
@@ -58,9 +85,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = true;
isMappedActive = false;
isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchLocal();
@@ -84,9 +113,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = false;
isMappedActive = true;
isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = false;
searchTerms = '';
tempSearchBox = null;
@@ -103,9 +134,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = true;
isStarredActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchAll();
@@ -201,6 +234,15 @@
>No {{ isProjectsActive ? "projects" : "people" }} were found with
that search.</span
>
<span v-else-if="isStarredActive">
<p>
You have no starred projects. Star some projects to see them here.
</p>
<p class="mt-4">
When you star projects, you will get a notice on the front page when
they change.
</p>
</span>
</p>
</div>
@@ -383,9 +425,12 @@ export default class DiscoverView extends Vue {
allMyDids: Array<string> = [];
apiServer = "";
isLoading = false;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = true;
isStarredActive = false;
isProjectsActive = true;
isPeopleActive = false;
isSearchVisible = true;
@@ -470,6 +515,8 @@ export default class DiscoverView extends Vue {
leafletObject: L.Map;
};
this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
} else if (this.isStarredActive) {
await this.searchStarred();
} else {
await this.searchAll();
}
@@ -540,6 +587,60 @@ export default class DiscoverView extends Vue {
}
}
public async searchStarred() {
this.resetCounts();
// Clear any previous results
this.projects = [];
this.userProfiles = [];
try {
this.isLoading = true;
// Get starred project IDs from settings
const settings = await this.$accountSettings();
const starredIds = settings.starredPlanHandleIds || [];
if (starredIds.length === 0) {
// No starred projects
return;
}
// This could be optimized to only pull those not already in the cache (endorserServer.ts)
const planHandleIdsJson = JSON.stringify(starredIds);
const endpoint =
this.apiServer +
"/api/v2/report/plans?planHandleIds=" +
encodeURIComponent(planHandleIdsJson);
const response = await this.axios.get(endpoint, {
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
this.notify.error("Failed to load starred projects", TIMEOUTS.SHORT);
return;
}
const starredPlans: PlanData[] = response.data.data;
if (response.data.hitLimit) {
// someday we'll have to let them incrementally load the rest
this.notify.warning(
"Beware: you have so many starred projects that we cannot load them all.",
TIMEOUTS.SHORT,
);
}
this.projects = starredPlans;
} catch (error: unknown) {
logger.error("Error loading starred projects:", error);
this.notify.error(
"Failed to load starred projects. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.isLoading = false;
}
}
public async searchLocal(beforeId?: string) {
this.resetCounts();
@@ -633,9 +734,12 @@ export default class DiscoverView extends Vue {
const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProject.rowId);
} else if (this.isStarredActive) {
this.searchStarred();
} else if (this.isAnywhereActive) {
this.searchAll(latestProject.rowId);
}
// Note: Starred tab doesn't support pagination since we load all starred projects at once
} else if (this.isPeopleActive && this.userProfiles.length > 0) {
const latestProfile = this.userProfiles[this.userProfiles.length - 1];
if (this.isLocalActive || this.isMappedActive) {
@@ -775,6 +879,24 @@ export default class DiscoverView extends Vue {
this.$router.push(route);
}
public computedStarredTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isStarredActive,
"text-black": this.isStarredActive,
"border-black": this.isStarredActive,
"font-semibold": this.isStarredActive,
"text-blue-600": !this.isStarredActive,
"border-transparent": !this.isStarredActive,
"hover:border-slate-400": !this.isStarredActive,
};
}
public computedLocalTabStyleClassNames() {
return {
"inline-block": true,

View File

@@ -319,9 +319,8 @@
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions to "Import Contacts".
(There is currently no way to import other settings, so you'll have to recreate
by hand your search area, filters, etc.)
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
Beware that this will erase your existing contact & settings.
</li>
</ul>
</div>
@@ -337,18 +336,14 @@
<h2 class="text-xl font-semibold">How do I erase my data from my device?</h2>
<p>
Before doing this, you should back up your data with the instructions above.
Note that this does not erase data sent to our servers (see contact info below)
Before doing this, you may want to back up your data with the instructions above.
</p>
<ul>
<li class="list-disc list-outside ml-4">
Mobile
<ul>
<li class="list-disc list-outside ml-4">
App Store app: hold down on the icon, then uninstall it
</li>
<li class="list-disc list-outside ml-4">
Home Screen PWA: hold down on the icon, and delete it
Home Screen: hold down on the icon, and choose to delete it
</li>
<li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
@@ -420,6 +415,15 @@
different page.
</p>
<h2 class="text-xl font-semibold">
Where do I get help with notifications?
</h2>
<p>
<router-link class="text-blue-500" to="/help-notifications"
>Here.</router-link
>
</p>
<h2 class="text-xl font-semibold">
This app is misbehaving, like showing me a blank screen or failing to show my personal data.
What can I do?
@@ -430,13 +434,10 @@
</p>
<ul class="list-disc list-outside ml-4">
<li>
For mobile apps, make sure you're connected to the internet.
</li>
<li>
For PWAs, drag down on the screen to refresh it; do that multiple times, because
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the latest version.
You can see the version information at the bottom of this page; the best
way to determine the latest version is to open TimeSafari.app in an incognito/private
way to determine the latest version is to open this page in an incognito/private
browser window and look at the version there.
</li>
<li>
@@ -467,6 +468,9 @@
</ul>
Then reload Time Safari.
</li>
<li>
Restart your device.
</li>
</ul>
<p>
If you still have problems, you can clear the cache (see "erase my data" above)
@@ -504,12 +508,16 @@
</p>
<ul class="list-disc list-outside ml-4">
<li>
If sending images, a server stores them. They can be removed by editing each claim
and deleting the image.
If using notifications, a server stores push token data. That can be revoked at any time
by disabling notifications on the Profile <font-awesome icon="circle-user" class="fa-fw" /> page.
</li>
<li>
If sending images, a server stores them, too. They can be removed by editing the claim
and deleting them.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request (via contact below).
data are stored on a server. Those can be removed via direct personal request.
</li>
<li>
For all other claim data,

View File

@@ -170,10 +170,10 @@ Raymer * @version 1.0.0 */
class="border-t p-2 border-slate-300"
@click="goToActivityToUserPage()"
>
<div class="flex justify-center">
<div class="flex justify-center gap-2">
<div
v-if="numNewOffersToUser"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
@@ -187,7 +187,7 @@ Raymer * @version 1.0.0 */
</div>
<div
v-if="numNewOffersToUserProjects"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
@@ -201,6 +201,22 @@ Raymer * @version 1.0.0 */
projects
</p>
</div>
<div
v-if="numNewStarredProjectChanges"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
data-testId="newStarredProjectChangesActivityNumber"
>
{{ numNewStarredProjectChanges
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
</span>
<p class="text-center">
favorite project{{ numNewStarredProjectChanges === 1 ? "" : "s" }}
with changes
</p>
</div>
</div>
<div class="flex justify-end mt-2">
<button class="text-blue-500">View All New Activity For You</button>
@@ -268,6 +284,7 @@ import {
getHeaders,
getNewOffersToUser,
getNewOffersToUserProjects,
getStarredProjectsWithChanges,
getPlanFromCache,
} from "../libs/endorserServer";
import {
@@ -283,6 +300,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications";
import * as Package from "../../package.json";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import * as databaseUtil from "../db/databaseUtil";
// consolidate this with GiveActionClaim in src/interfaces/claims.ts
interface Claim {
@@ -395,10 +413,14 @@ export default class HomeView extends Vue {
isRegistered = false;
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 project changes that they've acknowledged seeing
newOffersToUserHitLimit: boolean = false;
newOffersToUserProjectsHitLimit: boolean = false;
newStarredProjectChangesHitLimit: boolean = false;
numNewOffersToUser: number = 0; // number of new offers-to-user
numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects
numNewStarredProjectChanges: number = 0; // number of new starred project changes
starredPlanHandleIds: Array<string> = []; // list of starred project IDs
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
@@ -438,6 +460,7 @@ export default class HomeView extends Vue {
// Registration check already handled in initializeIdentity()
await this.loadFeedData();
await this.loadNewOffers();
await this.loadNewStarredProjectChanges();
await this.checkOnboarding();
} catch (err: unknown) {
this.handleError(err);
@@ -542,8 +565,14 @@ export default class HomeView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId;
this.lastAckedStarredPlanChangesJwtId =
settings.lastAckedStarredPlanChangesJwtId;
this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc;
this.starredPlanHandleIds = databaseUtil.parseJsonField(
settings.starredPlanHandleIds,
[],
);
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
// Check onboarding status
@@ -675,6 +704,43 @@ export default class HomeView extends Vue {
}
}
/**
* Loads new changes for starred projects
* Updates:
* - Number of new starred project changes
* - Rate limit status for starred project changes
*
* @internal
* Called by mounted() and initializeIdentity()
* @requires Active DID
*/
private async loadNewStarredProjectChanges() {
if (this.activeDid && this.starredPlanHandleIds.length > 0) {
try {
const starredProjectChanges = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature
logger.warn(
"[HomeView] Failed to load starred project changes:",
error,
);
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
} else {
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
}
/**
* Checks if user needs onboarding using ultra-concise mixin utilities
* Opens onboarding dialog if not completed

View File

@@ -88,15 +88,9 @@ import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import {
retrieveAccountCount,
importFromMnemonic,
checkForDuplicateAccount,
DUPLICATE_ACCOUNT_ERROR,
} from "../libs/util";
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications";
/**
* Import Account View Component
@@ -204,19 +198,6 @@ export default class ImportAccountView extends Vue {
}
try {
// Check for duplicate account before importing
const isDuplicate = await checkForDuplicateAccount(
this.mnemonic,
this.derivationPath,
);
if (isDuplicate) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
await importFromMnemonic(
this.mnemonic,
this.derivationPath,
@@ -242,20 +223,9 @@ export default class ImportAccountView extends Vue {
this.$router.push({ name: "account" });
} catch (error: unknown) {
this.$logError("Import failed: " + error);
// Check if this is a duplicate account error from saveNewIdentity
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes(DUPLICATE_ACCOUNT_ERROR)) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
this.notify.error(
errorMessage || "Failed to import account.",
(error instanceof Error ? error.message : String(error)) ||
"Failed to import account.",
TIMEOUTS.LONG,
);
}

View File

@@ -83,7 +83,6 @@ import {
retrieveAllAccountsMetadata,
retrieveFullyDecryptedAccount,
saveNewIdentity,
checkForDuplicateAccount,
} from "../libs/util";
import { logger } from "../utils/logger";
import { Account, AccountEncrypted } from "../db/tables/accounts";
@@ -172,16 +171,6 @@ export default class ImportAccountView extends Vue {
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
try {
// Check for duplicate account before creating
const isDuplicate = await checkForDuplicateAccount(newId.did);
if (isDuplicate) {
this.notify.warning(
"This derived account already exists. Please try a different derivation path.",
TIMEOUTS.LONG,
);
return;
}
await saveNewIdentity(newId, mne, newDerivPath);
// record that as the active DID

View File

@@ -29,7 +29,7 @@
v-if="newOffersToUser.length > 0"
:icon="showOffersDetails ? 'chevron-down' : 'chevron-right'"
class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserAndMarkRead()"
@click.prevent="expandOffersToUserAndMarkRead()"
/>
</div>
<router-link to="/recent-offers-to-user" class="text-blue-500">
@@ -48,7 +48,7 @@
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span>
offered
<span v-if="offer.objectDescription">{{
<span v-if="offer.objectDescription" class="truncate">{{
offer.objectDescription
}}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }}
@@ -67,10 +67,10 @@
<!-- New line that appears on hover or when the offer is clicked -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersAsReadStartingWith(offer.jwtId)"
@click.prevent="markOffersAsReadStartingWith(offer.jwtId)"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
Click to keep all above as unread offers
</div>
</li>
</ul>
@@ -96,7 +96,7 @@
showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserProjectsAndMarkRead()"
@click.prevent="expandOffersToUserProjectsAndMarkRead()"
/>
</div>
<router-link to="/recent-offers-to-user-projects" class="text-blue-500">
@@ -115,7 +115,7 @@
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span>
offered
<span v-if="offer.objectDescription">{{
<span v-if="offer.objectDescription" class="truncate">{{
offer.objectDescription
}}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }}
@@ -136,10 +136,153 @@
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)"
@click.prevent="
markOffersToUserProjectsAsReadStartingWith(offer.jwtId)
"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
Click to keep all above as unread offers
</div>
</li>
</ul>
</div>
<!-- Starred Projects with Changes Section -->
<div
class="flex justify-between mt-6"
data-testId="showStarredProjectChanges"
>
<div>
<span class="text-lg font-medium"
>{{ newStarredProjectChanges.length
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}</span
>
<span class="text-lg font-medium ml-4"
>Favorite Project{{
newStarredProjectChanges.length === 1 ? "" : "s"
}}
With Changes</span
>
<font-awesome
v-if="newStarredProjectChanges.length > 0"
:icon="
showStarredProjectChangesDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
@click.prevent="expandStarredProjectChangesAndMarkRead()"
/>
</div>
</div>
<div v-if="showStarredProjectChangesDetails" class="ml-4 mt-4">
<ul class="list-disc ml-4">
<li
v-for="projectChange in newStarredProjectChanges"
:key="projectChange.plan.handleId"
class="mt-4 relative group"
>
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0">
<span class="font-medium">{{
projectChange.plan.name || "Unnamed Project"
}}</span>
<span
v-if="projectChange.plan.description"
class="text-gray-600 block truncate"
>
{{ projectChange.plan.description }}
</span>
</div>
<router-link
:to="{
path:
'/project/' + encodeURIComponent(projectChange.plan.handleId),
}"
class="text-blue-500 flex-shrink-0"
>
<font-awesome
icon="file-lines"
class="text-blue-500 cursor-pointer"
/>
</router-link>
</div>
<!-- Show what changed -->
<div
v-if="getPlanDifferences(projectChange.plan.handleId)"
class="text-sm mt-2"
>
<div class="font-medium mb-2">Changes</div>
<div class="overflow-x-auto">
<table
class="w-full text-xs border-collapse border border-gray-300 rounded-lg shadow-sm bg-white"
>
<thead>
<tr class="bg-gray-50">
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
></th>
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
>
Previous
</th>
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
>
Current
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(difference, field) in getPlanDifferences(
projectChange.plan.handleId,
)"
:key="field"
class="hover:bg-gray-50"
>
<td
class="border border-gray-300 px-3 py-2 font-medium text-gray-800 break-words"
>
{{ getDisplayFieldName(field) }}
</td>
<td
class="border border-gray-300 px-3 py-2 text-gray-600 break-words align-top"
>
<vue-markdown
v-if="field === 'description' && difference.old"
:source="formatFieldValue(difference.old)"
class="text-sm markdown-content"
/>
<span v-else>{{ formatFieldValue(difference.old) }}</span>
</td>
<td
class="border border-gray-300 px-3 py-2 text-green-700 font-medium break-words align-top"
>
<vue-markdown
v-if="field === 'description' && difference.new"
:source="formatFieldValue(difference.new)"
class="text-sm markdown-content"
/>
<span v-else>{{ formatFieldValue(difference.new) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else>The changes did not affect essential project data.</div>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click.prevent="
markStarredProjectChangesAsReadStartingWith(
projectChange.plan.jwtId!,
)
"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as unread changes
</div>
</li>
</ul>
@@ -149,6 +292,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -159,20 +303,28 @@ import { Router } from "vue-router";
import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
import {
didInfo,
didInfoOrNobody,
displayAmount,
getNewOffersToUser,
getNewOffersToUserProjects,
getStarredProjectsWithChanges,
} from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import * as databaseUtil from "../db/databaseUtil";
import * as R from "ramda";
import { PlanActionClaim } from "../interfaces/claims";
import { GenericCredWrapper } from "@/interfaces";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
components: { GiftedDialog, QuickNav, EntityIcon, VueMarkdown },
mixins: [PlatformServiceMixin],
})
export default class NewActivityView extends Vue {
@@ -186,13 +338,22 @@ export default class NewActivityView extends Vue {
apiServer = "";
lastAckedOfferToUserJwtId = "";
lastAckedOfferToUserProjectsJwtId = "";
lastAckedStarredPlanChangesJwtId = "";
newOffersToUser: Array<OfferSummaryRecord> = [];
newOffersToUserHitLimit = false;
newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = [];
newOffersToUserProjectsHitLimit = false;
newStarredProjectChanges: Array<PlanSummaryAndPreviousClaim> = [];
newStarredProjectChangesHitLimit = false;
starredPlanHandleIds: Array<string> = [];
planDifferences: Record<
string,
Record<string, { old: unknown; new: unknown }>
> = {};
showOffersDetails = false;
showOffersToUserProjectsDetails = false;
showStarredProjectChangesDetails = false;
didInfo = didInfo;
displayAmount = displayAmount;
@@ -206,6 +367,12 @@ export default class NewActivityView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || "";
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || "";
this.lastAckedStarredPlanChangesJwtId =
settings.lastAckedStarredPlanChangesJwtId || "";
this.starredPlanHandleIds = databaseUtil.parseJsonField(
settings.starredPlanHandleIds,
[],
);
this.allContacts = await this.$getAllContacts();
@@ -229,6 +396,29 @@ export default class NewActivityView extends Vue {
this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit;
// Load starred project changes if user has starred projects
if (this.starredPlanHandleIds.length > 0) {
try {
const starredProjectChangesData = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.newStarredProjectChanges = starredProjectChangesData.data;
this.newStarredProjectChangesHitLimit =
starredProjectChangesData.hitLimit;
// Analyze differences between current plans and previous claims
this.analyzePlanDifferences(this.newStarredProjectChanges);
} catch (error) {
logger.warn("Failed to load starred project changes:", error);
this.newStarredProjectChanges = [];
this.newStarredProjectChangesHitLimit = false;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error retrieving settings & contacts:", err);
@@ -242,13 +432,13 @@ export default class NewActivityView extends Vue {
async expandOffersToUserAndMarkRead() {
this.showOffersDetails = !this.showOffersDetails;
if (this.showOffersDetails) {
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId,
});
// note that we don't update this.lastAckedOfferToUserJwtId in case they
// later choose the last one to keep the offers as new
this.notify.info(
"The offers are marked as viewed. Click in the list to keep them as new.",
"The offers are marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG,
);
}
@@ -260,12 +450,12 @@ export default class NewActivityView extends Vue {
);
if (index !== -1 && index < this.newOffersToUser.length - 1) {
// Set to the next offer's jwtId
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId,
});
}
@@ -279,14 +469,14 @@ export default class NewActivityView extends Vue {
this.showOffersToUserProjectsDetails =
!this.showOffersToUserProjectsDetails;
if (this.showOffersToUserProjectsDetails) {
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[0].jwtId,
});
// note that we don't update this.lastAckedOfferToUserProjectsJwtId in case
// they later choose the last one to keep the offers as new
this.notify.info(
"The offers are now marked as viewed. Click in the list to keep them as new.",
"The offers are now marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG,
);
}
@@ -298,13 +488,13 @@ export default class NewActivityView extends Vue {
);
if (index !== -1 && index < this.newOffersToUserProjects.length - 1) {
// Set to the next offer's jwtId
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.lastAckedOfferToUserProjectsJwtId,
});
@@ -314,5 +504,382 @@ export default class NewActivityView extends Vue {
TIMEOUTS.STANDARD,
);
}
async expandStarredProjectChangesAndMarkRead() {
this.showStarredProjectChangesDetails =
!this.showStarredProjectChangesDetails;
if (
this.showStarredProjectChangesDetails &&
this.newStarredProjectChanges.length > 0
) {
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId:
this.newStarredProjectChanges[0].plan.jwtId,
});
this.notify.info(
"The starred project changes are now marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG,
);
}
}
async markStarredProjectChangesAsReadStartingWith(jwtId: string) {
const index = this.newStarredProjectChanges.findIndex(
(change) => change.plan.jwtId === jwtId,
);
if (index !== -1 && index < this.newStarredProjectChanges.length - 1) {
// Set to the next change's jwtId
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId:
this.newStarredProjectChanges[index + 1].plan.jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId: this.lastAckedStarredPlanChangesJwtId,
});
}
this.notify.info(
"All starred project changes above that line are marked as unread.",
TIMEOUTS.STANDARD,
);
}
/**
* Analyzes differences between current plans and their previous claims
*
* Walks through a list of PlanSummaryAndPreviousClaim items and stores the
* differences between the previous claim and the current plan. This method
* extracts the claim from the wrappedClaimBefore object and compares relevant
* fields with the current plan.
*
* @param planChanges Array of PlanSummaryAndPreviousClaim objects to analyze
*/
analyzePlanDifferences(planChanges: Array<PlanSummaryAndPreviousClaim>) {
this.planDifferences = {};
for (const planChange of planChanges) {
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
"credentialSubject" in embeddedClaim
) {
// It's a Verifiable Credential
previousClaim =
(embeddedClaim.credentialSubject as PlanActionClaim) || embeddedClaim;
} else {
// It's a direct claim
previousClaim = embeddedClaim;
}
if (!previousClaim || !currentPlan.handleId) {
continue;
}
const differences: Record<string, { old: unknown; new: unknown }> = {};
// Compare name
const normalizedOldName = this.normalizeValueForComparison(
previousClaim.name,
);
const normalizedNewName = this.normalizeValueForComparison(
currentPlan.name,
);
if (!R.equals(normalizedOldName, normalizedNewName)) {
differences.name = {
old: previousClaim.name,
new: currentPlan.name,
};
}
// Compare description
const normalizedOldDescription = this.normalizeValueForComparison(
previousClaim.description,
);
const normalizedNewDescription = this.normalizeValueForComparison(
currentPlan.description,
);
if (!R.equals(normalizedOldDescription, normalizedNewDescription)) {
differences.description = {
old: previousClaim.description,
new: currentPlan.description,
};
}
// Compare location (combine latitude and longitude into one row)
const oldLat = this.normalizeValueForComparison(
previousClaim.location?.geo?.latitude,
);
const oldLon = this.normalizeValueForComparison(
previousClaim.location?.geo?.longitude,
);
const newLat = this.normalizeValueForComparison(currentPlan.locLat);
const newLon = this.normalizeValueForComparison(currentPlan.locLon);
if (!R.equals(oldLat, newLat) || !R.equals(oldLon, newLon)) {
differences.location = {
old: this.formatLocationValue(oldLat, oldLon, true),
new: this.formatLocationValue(newLat, newLon, false),
};
}
// Compare agent (issuer)
const oldAgent = didInfoOrNobody(
previousClaim.agent?.identifier,
this.activeDid,
this.allMyDids,
this.allContacts,
);
const newAgent = didInfoOrNobody(
currentPlan.agentDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
const normalizedOldAgent = this.normalizeValueForComparison(oldAgent);
const normalizedNewAgent = this.normalizeValueForComparison(newAgent);
if (!R.equals(normalizedOldAgent, normalizedNewAgent)) {
differences.agent = {
old: oldAgent,
new: newAgent,
};
}
// Compare start time
const oldStartTime = previousClaim.startTime;
const newStartTime = currentPlan.startTime;
const normalizedOldStartTime =
this.normalizeDateForComparison(oldStartTime);
const normalizedNewStartTime =
this.normalizeDateForComparison(newStartTime);
if (!R.equals(normalizedOldStartTime, normalizedNewStartTime)) {
differences.startTime = {
old: oldStartTime,
new: newStartTime,
};
}
// Compare end time
const oldEndTime = previousClaim.endTime;
const newEndTime = currentPlan.endTime;
const normalizedOldEndTime = this.normalizeDateForComparison(oldEndTime);
const normalizedNewEndTime = this.normalizeDateForComparison(newEndTime);
if (!R.equals(normalizedOldEndTime, normalizedNewEndTime)) {
differences.endTime = {
old: oldEndTime,
new: newEndTime,
};
}
// Compare image
const oldImage = previousClaim.image;
const newImage = currentPlan.image;
const normalizedOldImage = this.normalizeValueForComparison(oldImage);
const normalizedNewImage = this.normalizeValueForComparison(newImage);
if (!R.equals(normalizedOldImage, normalizedNewImage)) {
differences.image = {
old: oldImage,
new: newImage,
};
}
// Compare url
const oldUrl = previousClaim.url;
const newUrl = currentPlan.url;
const normalizedOldUrl = this.normalizeValueForComparison(oldUrl);
const normalizedNewUrl = this.normalizeValueForComparison(newUrl);
if (!R.equals(normalizedOldUrl, normalizedNewUrl)) {
differences.url = {
old: oldUrl,
new: newUrl,
};
}
// Store differences if any were found
if (!R.isEmpty(differences)) {
this.planDifferences[currentPlan.handleId] = differences;
logger.debug(
"[NewActivityView] Plan differences found for",
currentPlan.handleId,
differences,
);
}
}
logger.debug(
"[NewActivityView] Analyzed",
planChanges.length,
"plan changes, found differences in",
Object.keys(this.planDifferences).length,
"plans",
);
}
/**
* Normalizes values for comparison - treats null, undefined, and empty string as equivalent
*
* @param value The value to normalize
* @returns The normalized value (null for null/undefined/empty, otherwise the original value)
*/
normalizeValueForComparison<T>(value: T | null | undefined): T | null {
if (value === null || value === undefined || value === "") {
return null;
}
return value;
}
/**
* Normalizes date values for comparison by converting strings to Date objects
* Returns null for null/undefined/empty values, Date objects for valid date strings
*/
normalizeDateForComparison(value: unknown): Date | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value === "string") {
const date = new Date(value);
// Check if the date is valid
return isNaN(date.getTime()) ? null : date;
}
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
return null;
}
/**
* Gets the differences for a specific plan by handle ID
*
* @param handleId The handle ID of the plan to get differences for
* @returns The differences object or null if no differences found
*/
getPlanDifferences(
handleId: string,
): Record<string, { old: unknown; new: unknown }> | null {
return this.planDifferences[handleId] || null;
}
/**
* Formats a field value for display in the UI
*
* @param value The value to format
* @returns A human-readable string representation
*/
formatFieldValue(value: unknown): string {
if (value === null || value === undefined) {
return "Not set";
}
if (typeof value === "string") {
const stringValue = value || "Empty";
// Check if it's a date/time string
if (this.isDateTimeString(stringValue)) {
return this.formatDateTime(stringValue);
}
// Check if it's a URL
if (this.isUrl(stringValue)) {
return stringValue; // Keep URLs as-is for now
}
return stringValue;
}
if (typeof value === "number") {
return value.toString();
}
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
// For complex objects, stringify
const stringified = JSON.stringify(value);
return stringified;
}
/**
* Checks if a string appears to be a date/time string
*/
isDateTimeString(value: string): boolean {
if (!value) return false;
// Check for ISO 8601 format or other common date formats
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?(\.\d{3})?Z?$/;
return dateRegex.test(value) || !isNaN(Date.parse(value));
}
/**
* Checks if a string is a URL
*/
isUrl(value: string): boolean {
if (!value) return false;
try {
new URL(value);
return true;
} catch {
return false;
}
}
/**
* Formats a date/time string for display
*/
formatDateTime(value: string): string {
try {
const date = new Date(value);
return date.toLocaleString();
} catch {
return value; // Return original if parsing fails
}
}
/**
* Gets a human-readable field name for display
*
* @param fieldName The internal field name
* @returns A formatted field name for display
*/
getDisplayFieldName(fieldName: string): string {
const fieldNameMap: Record<string, string> = {
name: "Name",
description: "Description",
location: "Location",
agent: "Agent",
startTime: "Start Time",
endTime: "End Time",
image: "Image",
url: "URL",
};
return fieldNameMap[fieldName] || fieldName;
}
/**
* Formats location values for display
*
* @param latitude The latitude value
* @param longitude The longitude value
* @param isOldValue Whether this is the old value (true) or new value (false)
* @returns A formatted location string
*/
formatLocationValue(
latitude: number | undefined | null,
longitude: number | undefined | null,
isOldValue: boolean = false,
): string {
if (latitude == null && longitude == null) {
return "Not set";
}
// If there's any location data, show generic labels instead of coordinates
if (isOldValue) {
return "A Location";
} else {
return "New Location";
}
}
}
</script>

View File

@@ -110,22 +110,10 @@ export default class NewEditAccountView extends Vue {
* @async
*/
async onClickSaveChanges() {
// Get the current active DID to save to user-specific settings
const settings = await this.$accountSettings();
const activeDid = settings.activeDid;
if (activeDid) {
// Save to user-specific settings for the current identity
await this.$saveUserSettings(activeDid, {
await this.$updateSettings({
firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
});
} else {
// Fallback to master settings if no active DID
await this.$saveSettings({
firstName: this.givenName,
});
}
this.$router.back();
}

View File

@@ -27,10 +27,18 @@
>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
<button title="Copy Link to Project" @click="onCopyLinkClick()">
<button
:title="
isStarred
? 'Remove from starred projects'
: 'Add to starred projects'
"
@click="toggleStar()"
>
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
:icon="isStarred ? 'star' : ['far', 'star']"
:class="isStarred ? 'text-yellow-500' : 'text-slate-500'"
class="text-sm ml-2 mb-1"
/>
</button>
</h2>
@@ -58,13 +66,13 @@
icon="user"
class="fa-fw text-slate-400"
></font-awesome>
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
<span class="truncate max-w-[calc(100%-2rem)] ml-1">
{{ issuerInfoObject?.displayName }}
</span>
<span
v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center"
class="inline-flex items-center ml-1"
>
<router-link
:to="{
@@ -140,18 +148,22 @@
<div class="text-sm text-slate-500">
<div v-if="!expanded">
{{ truncatedDesc }}
<vue-markdown
:source="truncatedDesc"
class="mb-4 markdown-content"
/>
<a
v-if="description.length >= truncateLength"
class="uppercase text-xs font-semibold text-slate-700"
class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="expandText"
>... Read More</a
>
</div>
<div v-else>
{{ description }}
<vue-markdown :source="description" class="mb-4 markdown-content" />
<a
class="uppercase text-xs font-semibold text-slate-700"
v-if="description.length >= truncateLength"
class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="collapseText"
>- Read Less</a
>
@@ -592,7 +604,10 @@
<script lang="ts">
import { AxiosError } from "axios";
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import {
GenericVerifiableCredential,
GenericCredWrapper,
@@ -603,25 +618,24 @@ import {
PlanSummaryRecord,
} from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import OfferDialog from "../components/OfferDialog.vue";
import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { NotificationIface } from "../constants/app";
// Removed legacy logging import - migrated to PlatformServiceMixin
import { APP_SERVER, NotificationIface } from "../constants/app";
import { UNNAMED_PROJECT } from "../constants/entities";
import { NOTIFY_CONFIRM_CLAIM } from "../constants/notifications";
import * as databaseUtil from "../db/databaseUtil";
import { Contact } from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger";
import { useClipboard } from "@vueuse/core";
import { logger } from "@/utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONFIRM_CLAIM } from "@/constants/notifications";
import { APP_SERVER } from "@/constants/app";
import { UNNAMED_PROJECT } from "@/constants/entities";
/**
* Project View Component
* @author Matthew Raymer
@@ -663,6 +677,7 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
ProjectIcon,
QuickNav,
TopMessage,
VueMarkdown,
},
mixins: [PlatformServiceMixin],
})
@@ -718,6 +733,8 @@ export default class ProjectViewView extends Vue {
givesProvidedByHitLimit = false;
givesTotalsByUnit: Array<{ unit: string; amount: number }> = [];
imageUrl = "";
/** Whether this project is starred by the user */
isStarred = false;
/** Project issuer DID */
issuer = "";
/** Cached issuer information */
@@ -756,7 +773,7 @@ export default class ProjectViewView extends Vue {
totalsExpanded = false;
truncatedDesc = "";
/** Truncation length */
truncateLength = 40;
truncateLength = 200;
// Utility References
libsUtil = libsUtil;
@@ -805,6 +822,12 @@ export default class ProjectViewView extends Vue {
}
this.loadProject(this.projectId, this.activeDid);
this.loadTotals();
// Check if this project is starred when settings are loaded
if (this.projectId && settings.starredPlanHandleIds) {
const starredIds = settings.starredPlanHandleIds || [];
this.isStarred = starredIds.includes(this.projectId);
}
}
/**
@@ -880,7 +903,7 @@ export default class ProjectViewView extends Vue {
);
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
this.description = resp.data.claim?.description || "";
this.truncatedDesc = this.description.slice(0, this.truncateLength);
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
@@ -1470,5 +1493,72 @@ export default class ProjectViewView extends Vue {
this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0
);
}
/**
* Toggle the starred status of the current project
*/
async toggleStar() {
if (!this.projectId) return;
try {
if (!this.isStarred) {
// Add to starred projects
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
const starredIds = settings.starredPlanHandleIds || [];
if (!starredIds.includes(this.projectId)) {
const newStarredIds = [...starredIds, this.projectId];
const newIdsParam = JSON.stringify(newStarredIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = true;
} else {
// eslint-disable-next-line no-console
console.log(
"Still getting a bad result from SQL update to star a project.",
);
}
}
if (!settings.lastAckedStarredPlanChangesJwtId) {
await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId: settings.lastViewedClaimId,
});
}
} else {
// Remove from starred projects
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
const starredIds = settings.starredPlanHandleIds || [];
const updatedIds = starredIds.filter((id) => id !== this.projectId);
const newIdsParam = JSON.stringify(updatedIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = false;
} else {
// eslint-disable-next-line no-console
logger.error("Got a bad result from SQL update to unstar a project.");
}
}
} catch (error) {
logger.error("Error toggling star status:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to update starred status. Please try again.",
},
3000,
);
}
}
}
</script>

View File

@@ -69,17 +69,10 @@
<div v-if="claimCountWithHidden > 0" class="border-b border-slate-300 pb-2">
<span>
{{ claimCountWithHiddenText }}
If you don't see expected info above for someone, ask them to check that
their activity is visible to you (
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
) on
<a
class="text-blue-500 underline cursor-pointer"
@click="copyContactsLinkToClipboard"
>
this page </a
>.
so if you expected but do not see details from someone then ask them to
check that their activity is visible to you on their Contacts
<font-awesome icon="users" class="text-slate-500" />
page.
</span>
</div>
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
@@ -127,11 +120,10 @@ import { DateTime } from "luxon";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface, APP_SERVER } from "../constants/app";
import { NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
GenericCredWrapper,
@@ -156,7 +148,6 @@ import {
NOTIFY_ALL_CONFIRMATIONS_ERROR,
NOTIFY_GIVE_SEND_ERROR,
NOTIFY_CLAIMS_SEND_ERROR,
NOTIFY_COPIED_TO_CLIPBOARD,
createConfirmationSuccessMessage,
createCombinedSuccessMessage,
} from "@/constants/notifications";
@@ -204,8 +195,8 @@ export default class QuickActionBvcEndView extends Vue {
get claimCountWithHiddenText() {
if (this.claimCountWithHidden === 0) return "";
return this.claimCountWithHidden === 1
? "There is 1 other claim with hidden details."
: `There are ${this.claimCountWithHidden} other claims with hidden details.`;
? "There is 1 other claim with hidden details,"
: `There are ${this.claimCountWithHidden} other claims with hidden details,`;
}
get claimCountByUserText() {
@@ -305,25 +296,6 @@ export default class QuickActionBvcEndView extends Vue {
(this.$router as Router).push(route);
}
copyContactsLinkToClipboard() {
const deepLinkUrl = `${APP_SERVER}/deep-link/did/${this.activeDid}`;
useClipboard()
.copy(deepLinkUrl)
.then(() => {
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message("Your info link"),
TIMEOUTS.SHORT,
);
})
.catch((error) => {
logger.error("Failed to copy to clipboard:", error);
this.notify.error(
"Failed to copy link to clipboard. Please try again.",
TIMEOUTS.SHORT,
);
});
}
async record() {
try {
if (this.claimsToConfirmSelected.length > 0) {

View File

@@ -69,7 +69,8 @@
*/
import { test, expect } from '@playwright/test';
import { createContactName, generateNewEthrUser, importUser, importUserFromAccount } from './testUtils';
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUtils';
import { NOTIFY_CONTACT_INVALID_DID } from '../src/constants/notifications';
test('Check activity feed - check that server is running', async ({ page }) => {
@@ -184,20 +185,35 @@ test('Check invalid DID shows error and redirects', async ({ page }) => {
});
test('Check User 0 can register a random person', async ({ page }) => {
const newDid = await generateNewEthrUser(page); // generate a new user
await importUser(page, '00');
const newDid = await generateAndRegisterEthrUser(page);
expect(newDid).toContain('did:ethr:');
await importUserFromAccount(page, "00"); // switch to User Zero
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill('Gave me access!');
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// now ensure that alert goes away
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss alert
await expect(page.getByText('That gift was recorded.')).toBeHidden();
// As User Zero, add the new user as a contact
await page.goto('./contacts');
const contactName = createContactName(newDid);
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(`${newDid}, ${contactName}`);
await expect(page.locator('button > svg.fa-plus')).toBeVisible();
await page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"] h4:has-text("Success")')).toBeVisible(); // wait for info alert to be visible…
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // …and dismiss it
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
await page.locator('div[role="alert"] button:text-is("Yes")').click(); // Register new contact
await page.locator('div[role="alert"] button:text-is("No, Not Now")').click(); // Dismiss export data prompt
await expect(page.locator("li", { hasText: contactName })).toBeVisible();
// now delete the contact to test that pages still do reasonable things
await deleteContact(page, newDid);
// go the activity page for this new person
await page.goto('./did/' + encodeURIComponent(newDid));
// maybe replace by: const popupPromise = page.waitForEvent('popup');
let error;
try {
await page.waitForSelector('div[role="alert"]', { timeout: 2000 });
error = new Error('Error alert should not show.');
} catch (error) {
// success
} finally {
if (error) {
throw error;
}
}
});

View File

@@ -1,63 +0,0 @@
import { test, expect } from '@playwright/test';
import { importUserFromAccount, getTestUserData } from './testUtils';
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from '../src/constants/notifications';
/**
* Test duplicate account import functionality
*
* This test verifies that:
* 1. A user can successfully import an account the first time
* 2. Attempting to import the same account again shows a warning message
* 3. The duplicate import is prevented
*/
test.describe('Duplicate Account Import', () => {
test('should prevent importing the same account twice', async ({ page }) => {
const userData = getTestUserData("00");
// First import - should succeed
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userData.did);
// Navigate back to start page for second import attempt
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify duplicate import shows warning message
// The warning can appear either from the pre-check or from the saveNewIdentity error handling
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).toBeVisible();
// Verify we're still on the import page (not redirected to account)
await expect(page.getByPlaceholder("Seed Phrase")).toBeVisible();
});
test('should allow importing different accounts', async ({ page }) => {
const userZeroData = getTestUserData("00");
const userOneData = getTestUserData("01");
// Import first user
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userZeroData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify first import was successful
await expect(page.getByRole("code")).toContainText(userZeroData.did);
// Navigate back to start page for second user import
await page.goto("./start");
await page.getByText("You have a seed").click();
await page.getByPlaceholder("Seed Phrase").fill(userOneData.seedPhrase);
await page.getByRole("button", { name: "Import" }).click();
// Verify second import was successful (should not show duplicate warning)
await expect(page.getByRole("code")).toContainText(userOneData.did);
await expect(page.getByText(NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message)).not.toBeVisible();
});
});

View File

@@ -8,7 +8,6 @@
* - Custom expiration date
* 2. The invitation appears in the list after creation
* 3. A new user can accept the invitation and become connected
* 4. The new user can create gift records from the front page
*
* Test Flow:
* 1. Imports User 0 (test account)
@@ -20,8 +19,6 @@
* 4. Creates a new user with Ethr DID
* 5. Accepts the invitation as the new user
* 6. Verifies the connection is established
* 7. Tests that the new user can create gift records from the front page
* 8. Verifies the gift appears in the home view
*
* Related Files:
* - Frontend invite handling: src/libs/endorserServer.ts
@@ -32,7 +29,7 @@
* @requires ./testUtils - For user management utilities
*/
import { test, expect } from '@playwright/test';
import { createGiftFromFrontPageForNewUser, deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils';
import { deleteContact, generateNewEthrUser, generateRandomString, importUser, switchToUser } from './testUtils';
test('Check User 0 can invite someone', async ({ page }) => {
await importUser(page, '00');
@@ -61,7 +58,4 @@ test('Check User 0 can invite someone', async ({ page }) => {
await page.locator('button:has-text("Save")').click();
await expect(page.locator('button:has-text("Save")')).toBeHidden();
await expect(page.locator(`li:has-text("My pal User #0")`)).toBeVisible();
// Verify the new user can create a gift record from the front page
const giftTitle = await createGiftFromFrontPageForNewUser(page, `Gift from new user ${neighborNum}`);
});

View File

@@ -1,5 +1,4 @@
import { expect, Page } from "@playwright/test";
import { UNNAMED_ENTITY_NAME } from '../src/constants/entities';
// Get test user data based on the ID.
// '01' -> user 111
@@ -110,7 +109,7 @@ export async function switchToUser(page: Page, did: string): Promise<void> {
await page.getByTestId("didWrapper").locator('code:has-text("did:")');
}
export function createContactName(did: string): string {
function createContactName(did: string): string {
return "User " + did.slice(11, 14);
}
@@ -145,10 +144,35 @@ export async function generateNewEthrUser(page: Page): Promise<string> {
return newDid;
}
// Generate a new random user and register them.
// Note that this makes 000 the active user. Use switchToUser to switch to this DID.
export async function generateAndRegisterEthrUser(page: Page): Promise<string> {
const newDid = await generateNewEthrUser(page);
await importUser(page, "000"); // switch to user 000
await page.goto("./contacts");
const contactName = createContactName(newDid);
await page
.getByPlaceholder("URL or DID, Name, Public Key")
.fill(`${newDid}, ${contactName}`);
await page.locator("button > svg.fa-plus").click();
// register them
await page.locator('div[role="alert"] button:text-is("Yes")').click();
// wait for it to disappear because the next steps may depend on alerts being gone
await expect(
page.locator('div[role="alert"] button:text-is("Yes")')
).toBeHidden();
await expect(page.locator("li", { hasText: contactName })).toBeVisible();
return newDid;
}
// Function to generate a random string of specified length
// Note that this only generates up to 10 characters
export async function generateRandomString(length: number): Promise<string> {
return Math.random()
.toString(36)
.toString(36) // base 36 only generates up to 10 characters
.substring(2, 2 + length);
}
@@ -157,7 +181,7 @@ export async function createUniqueStringsArray(
count: number
): Promise<string[]> {
const stringsArray: string[] = [];
const stringLength = 16;
const stringLength = 5; // max of 10; see generateRandomString
for (let i = 0; i < count; i++) {
let randomString = await generateRandomString(stringLength);
@@ -216,44 +240,3 @@ export function isResourceIntensiveTest(testPath: string): boolean {
testPath.includes("40-add-contact")
);
}
/**
* Create a gift record from the front page
* @param page - Playwright page object
* @param giftTitle - Optional custom title, defaults to "Gift " + random string
* @param amount - Optional amount, defaults to random 1-99
* @returns Promise resolving to the created gift title
*/
export async function createGiftFromFrontPageForNewUser(
page: Page,
giftTitle?: string,
amount?: number
): Promise<void> {
// Generate random values if not provided
const randomString = Math.random().toString(36).substring(2, 6);
const finalTitle = giftTitle || `Gift ${randomString}`;
const finalAmount = amount || Math.floor(Math.random() * 99) + 1;
// Navigate to home page and close onboarding
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
// Start gift creation flow
await page.getByRole('button', { name: 'Person' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
// Fill gift details
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(finalAmount.toString());
// Submit gift
await page.getByRole('button', { name: 'Sign & Send' }).click();
// Verify success
await expect(page.getByText('That gift was recorded.')).toBeVisible();
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
// Verify the gift appears in the home view
await page.goto('./');
await expect(page.locator('ul#listLatestActivity li').filter({ hasText: giftTitle })).toBeVisible();
}